diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 3759567..cc75330 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -12,8 +12,9 @@ import CommentMarkdown from "@/lib/docs/comments/CommentMarkdown"; // // shell → overlay: jh:anchors (resolve+paint), jh:reactions (chips+paint), // jh:active (hover sync), jh:focus (pin/focus a key), -// jh:scrollTo, jh:clearSelection, jh:ping -// overlay → shell: jh:ready, jh:positions (highlight y → card alignment), +// jh:clearSelection, jh:ping +// overlay → shell: jh:ready, jh:positions (highlight y → card alignment, +// docHeight → iframe sizing), // jh:selection / jh:selectionCleared (selection toolbar), // jh:focus (segment clicked: focused key + covering set), // jh:hlHover / jh:hlHoverOut (highlight ↔ card sync), @@ -24,11 +25,14 @@ import CommentMarkdown from "@/lib/docs/comments/CommentMarkdown"; // covering set. The shell only needs to map comment ids ↔ rail cards; reaction // focus has no card (chips are inline). See lib/docs/overlay.ts. // -// Reproduces the variant-b.html interaction feel: rail cards aligned to their -// highlight's vertical position with no-overlap clamping; cards expand in place -// to reply; show-resolved toggle; dashed orphan cards; Gravatar avatars; -// vertical selection toolbar (add comment + react). All persistence goes through -// /api/v1/docs/:slug/comments and /reactions; the API enforces permissions. +// Google-docs scroll model: the iframe is sized to the document's full content +// height (from jh:positions docHeight) so it never scrolls internally — the PAGE +// owns the one scrollbar. Cards sit in a margin column to the right of the doc at +// their highlight's document Y (with no-overlap clamping), so doc and comments +// scroll together as one surface. Cards expand in place to reply; show-resolved +// toggle; dashed orphan cards; Gravatar avatars; vertical selection toolbar (add +// comment + react). All persistence goes through /api/v1/docs/:slug/comments and +// /reactions; the API enforces permissions. const MONO = `ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace`; // The picker set — the curated brand emoji (birthday.md B11 example set). Every @@ -127,10 +131,8 @@ export default function CommentsShell(props: Props) { const [pinnedId, setPinnedId] = useState(null); const [activeId, setActiveId] = useState(null); const [positions, setPositions] = useState>({}); - // The doc's live scroll offset + total height (from the overlay). On desktop the - // rail scrolls to match docScrollY so each card tracks its highlight instead of - // being stranded at an absolute Y in an otherwise-empty rail. - const [docScrollY, setDocScrollY] = useState(0); + // The doc's total content height (from the overlay). The iframe is sized to it + // so the page owns the single scrollbar and doc + cards scroll as one surface. const [docHeight, setDocHeight] = useState(0); const [overlayReady, setOverlayReady] = useState(false); @@ -174,7 +176,9 @@ export default function CommentsShell(props: Props) { const isDark = palette !== null; // Selection state (from the overlay) → the floating toolbar + a pending draft. - const [selection, setSelection] = useState<{ anchor: NonNullable; top: number; viewTop: number } | null>(null); + // top is document-space Y — identical to the wrapper's coordinate space since + // the iframe never scrolls internally. + const [selection, setSelection] = useState<{ anchor: NonNullable; top: number } | null>(null); const [draft, setDraft] = useState<{ anchor: NonNullable; top: number } | null>(null); const apiBase = `/api/v1/docs/${encodeURIComponent(slug)}`; @@ -271,7 +275,6 @@ export default function CommentsShell(props: Props) { break; case "jh:positions": setPositions(d.positions || {}); - if (typeof d.scrollY === "number") setDocScrollY(d.scrollY); if (typeof d.docHeight === "number") setDocHeight(d.docHeight); break; case "jh:theme": @@ -291,7 +294,7 @@ export default function CommentsShell(props: Props) { break; case "jh:selection": if ((canComment || canReact) && d.anchor && d.anchor.exact) { - setSelection({ anchor: d.anchor, top: d.rect?.top ?? 0, viewTop: d.rect?.viewTop ?? 0 }); + setSelection({ anchor: d.anchor, top: d.rect?.top ?? 0 }); } break; case "jh:selectionCleared": @@ -338,6 +341,16 @@ export default function CommentsShell(props: Props) { return () => window.removeEventListener("message", onMsg); }, [paintAnchors, paintReactionGroups, me, avatars, postToOverlay, canComment, canReact]); + // Handshake: the overlay fires jh:ready when its script runs, which can beat + // this component's listener — the SSR'd iframe starts fetching before React + // hydrates — and a missed ready means anchors are never sent. Ping until the + // overlay answers (it replies to jh:ping with jh:ready). + useEffect(() => { + if (overlayReady) return; + const t = setInterval(() => postToOverlay({ type: "jh:ping" }), 250); + return () => clearInterval(t); + }, [overlayReady, postToOverlay]); + const reload = useCallback(async () => { const r = await fetch(`${apiBase}/comments${tokenQuery}`, { credentials: "same-origin" }); if (r.ok) { @@ -475,11 +488,6 @@ export default function CommentsShell(props: Props) { [threads, showResolved] ); - // Card vertical layout: anchored cards align to their highlight y (from the - // overlay's reported positions) with no-overlap clamping; doc-level + orphaned - // stack after. Reproduces variant-b.html's docs-style alignment. - const railRef = useRef(null); - // ---- Responsive rail (variant A — right drawer) ---- // DESKTOP (>768px): side-by-side as before; the toggle collapses/expands the // rail to reclaim width (default = open). MOBILE (<=768px): the doc iframe is @@ -494,14 +502,12 @@ export default function CommentsShell(props: Props) { // comment change never clobbers a manual toggle. const hadCommentsAtLoad = props.initialThreads.length > 0; const [railOpen, setRailOpen] = useState(false); - const isMobileRef = useRef(false); const [isMobile, setIsMobile] = useState(false); useEffect(() => { const mq = window.matchMedia("(max-width: 768px)"); // Desktop: open by default ONLY if the doc already has comments. Mobile: // always closed by default (the toggle opens the right drawer). const applyDefault = () => { - isMobileRef.current = mq.matches; setIsMobile(mq.matches); setRailOpen(!mq.matches && hadCommentsAtLoad); }; @@ -512,30 +518,22 @@ export default function CommentsShell(props: Props) { // The count shown in the toggle: number of threads (visible roots). const commentCount = threads.length; - // Docs-style scroll sync: cards are laid out at their highlight's absolute - // document Y, so the rail must scroll in lockstep with the document or a deep - // comment's card is stranded far below an empty rail. - useEffect(() => { - const el = railRef.current; - if (!el) return; - // Mobile drawer: cards stack from the top (no absolute Y), so clear any leftover - // desktop scroll offset — a stale large scrollTop would open the drawer scrolled - // past the stacked cards onto empty space. - if (isMobile) { - el.scrollTop = 0; - return; - } - // The cards list starts BELOW the sticky header + doc-reaction/sign-in rows, but - // card Y is measured from the document top. Offset the sync by that chrome height - // (the list's own offsetTop) so a card lines up with its highlight instead of - // sitting that far below it. - const cards = el.querySelector("[data-jh-cards]") as HTMLElement | null; - const chromeH = cards ? cards.offsetTop : 0; - el.scrollTop = docScrollY + chromeH; - // docHeight is a dep though unused above: when it grows (late layout / resize) the - // rail's scrollHeight grows with it, so a scrollTop the browser previously clamped - // too low must be reapplied — otherwise cards stay offset until the next doc scroll. - }, [docScrollY, docHeight, isMobile, railOpen]); + // Google-docs behavior: focusing a card brings its highlighted text into view. + // The sandboxed iframe can't scroll the page (it never scrolls internally and + // its opaque origin blocks scrollIntoView propagation), so the shell scrolls + // the window to the highlight's document Y when it's offscreen. + const scrollHighlightIntoView = useCallback( + (id: number) => { + const y = positions[id]; + const stage = stageRef.current; + if (y == null || !stage) return; + const target = stage.getBoundingClientRect().top + window.scrollY + y; + if (target < window.scrollY + 80 || target > window.scrollY + window.innerHeight - 120) { + window.scrollTo({ top: Math.max(0, target - window.innerHeight / 3), behavior: "smooth" }); + } + }, + [positions] + ); // When dark, expose the variant-D palette as CSS custom properties on the // wrapper. Every themed color below reads `var(--jh-x, )`, so @@ -577,15 +575,26 @@ export default function CommentsShell(props: Props) { src={rawSrc} sandbox="allow-scripts" referrerPolicy="no-referrer" - style={{ border: "none", width: "100%", height: "100%", display: "block", background: "var(--jh-stage-bg, #fff)" }} + style={{ + border: "none", + width: "100%", + // Sized to the doc's full content height so the iframe never scrolls + // internally — the page scrollbar is the only one. Until the overlay's + // first docHeight report, fall back to a viewport-height pane (a tall + // doc scrolls inside the iframe for that first beat). + height: docHeight || undefined, + minHeight: "calc(100vh - 2.4rem)", + display: "block", + background: "var(--jh-stage-bg, #fff)", + }} /> - {/* selection toolbar — overlaid on the docwrap; positioned to the - selection's viewport-top within the iframe. Shows on selection when + {/* selection toolbar — overlaid on the docwrap at the selection's + document Y, so it scrolls with the page. Shows on selection when the viewer can comment OR react (react-only viewers still get the react affordance). */} {selection && (canComment || canReact) ? ( { @@ -618,8 +627,8 @@ export default function CommentsShell(props: Props) { style={scrimStyle} /> -