/** * Comment controls painted over the document, never into it. * * The collaborative tree is prose alone. Thread chrome belongs to one reader, * so this adapter resolves its anchors into DOM rectangles and portals ordinary * React controls into the document page. */ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { DraftCard, ThreadCard } from "./comments"; import { gutterPoint, popoverPoint } from "./comment-geometry"; import { $rangeOf } from "./marks"; import { blockElement } from "./scroll"; import { useThreads } from "./threads"; import type { CSSProperties } from "react"; import type { Point, Rect } from "./comment-geometry"; import type { ThreadStore, ThreadView } from "./threads"; type PlacedThread = { view: ThreadView; button: Point }; function rect(value: DOMRect): Rect { return { top: value.top, right: value.right, bottom: value.bottom, left: value.left, width: value.width, height: value.height, }; } /** A small thread summary while a reader is deciding whether to open it. */ function Preview({ onEnter, onLeave, style, view, }: { onEnter: () => void; onLeave: () => void; style: CSSProperties; view: ThreadView; }) { let replies = Math.max(0, view.thread.notes.length - 1); return (

{view.quote}

{replies > 0 && {replies} {replies === 1 ? "reply" : "replies"}}
); } export function CommentLayer({ store }: { store: ThreadStore }) { let [editor] = useLexicalComposerContext(); let state = useThreads(store); let [host, setHost] = useState(); let [placed, setPlaced] = useState([]); let [preview, setPreview] = useState(); let [pinned, setPinned] = useState(); let [cardHeights, setCardHeights] = useState<{ [id: string]: number }>({}); let root = useRef(null); let close = useRef | undefined>(undefined); let failures = useRef(new Set()); useEffect(() => { setHost(document.querySelector(".plan-document") ?? undefined); }, []); let measure = () => { if (!host) return; let page = rect(host.getBoundingClientRect()); let next: PlacedThread[] = []; for (let view of state.threads) { if (view.thread.status !== "open") continue; try { let target = editor.getEditorState().read(() => { let exact = view.places[0] && $rangeOf(editor, view.places[0]); return exact?.getBoundingClientRect() ?? (view.targetKey ? blockElement(editor, view.targetKey)?.getBoundingClientRect() : undefined); }); if (!target || (target.width === 0 && target.height === 0)) continue; let targetRect = rect(target); next.push({ view, button: gutterPoint(targetRect, page) }); } catch (error) { // This may run from a Lexical update listener. A broken anchor is // one lost button, never a broken update chain. if (failures.current.has(view.thread.id)) continue; failures.current.add(view.thread.id); console.error(`[plan] could not measure comment ${view.thread.id}:`, error); } } setPlaced(next); }; useLayoutEffect(() => { measure(); }, [host, state.threads]); useEffect(() => { if (!host) return; let update = () => measure(); let off = editor.registerUpdateListener(update); host.addEventListener("scroll", update, true); let observer = new ResizeObserver(update); observer.observe(host); return () => { off(); host.removeEventListener("scroll", update, true); observer.disconnect(); }; }, [editor, host, state.threads]); useEffect(() => () => clearTimeout(close.current), []); useEffect(() => { if (!pinned) return; let outside = (event: PointerEvent) => { if (root.current?.contains(event.target as Node)) return; setPinned(undefined); }; let escape = (event: KeyboardEvent) => { if (event.key === "Escape") setPinned(undefined); }; document.addEventListener("pointerdown", outside); document.addEventListener("keydown", escape); return () => { document.removeEventListener("pointerdown", outside); document.removeEventListener("keydown", escape); }; }, [pinned]); if (!host) return null; let orphaned = state.threads.filter(view => view.thread.status === "open" && view.orphaned); let enter = (id: string) => { clearTimeout(close.current); setPreview(id); store.focus(id); }; let leave = (id: string) => { clearTimeout(close.current); close.current = setTimeout(() => { if (pinned !== id) setPreview(current => current === id ? undefined : current); store.focus(undefined); }, 100); }; let card = (view: ThreadView) => ( store.accept(view.thread.id)} onBlur={() => leave(view.thread.id)} onDismiss={() => store.dismiss(view.thread.id)} onFocus={() => enter(view.thread.id)} onReply={text => store.reply(view.thread.id, text)} onRetry={() => store.retry(view.thread.id)} onReveal={() => store.reveal(view.thread.id)} onTyping={writing => store.announce(view.thread.id, writing)} quote={view.quote} view={view} writing={state.writing[view.thread.id]} /> ); let rememberHeight = (id: string, element: HTMLDivElement | null) => { let height = element?.offsetHeight; if (!height) return; setCardHeights(current => current[id] === height ? current : { ...current, [id]: height }); }; let page = host.getBoundingClientRect(); let cardWidth = Math.min(384, host.clientWidth * 0.8); return createPortal(
{placed.map(({ button, view }) => { let shown = pinned === view.thread.id; let cardPoint = popoverPoint( { top: page.top + button.top, left: page.left + button.left, right: page.left + button.left + 24, bottom: page.top + button.top + 24, width: 24, height: 24, }, page, cardWidth, cardHeights[view.thread.id] ?? 0, ); return (
{preview === view.thread.id && !shown && ( enter(view.thread.id)} onLeave={() => leave(view.thread.id)} style={{ top: button.top, left: button.left + 32 }} view={view} /> )} {shown && (
rememberHeight(view.thread.id, element)} onMouseEnter={() => enter(view.thread.id)} onMouseLeave={() => leave(view.thread.id)} role="dialog" style={cardPoint} > {card(view)}
)}
); })} {state.draft?.placement && (
rememberHeight("draft", element)} style={popoverPoint( state.draft.placement, page, cardWidth, cardHeights.draft ?? 0, )} > store.draft(undefined)} onSend={text => store.start(text)} quote={state.draft.quote} />
)} {orphaned.length > 0 && (
{pinned === "orphans" && (
{orphaned.map(card)}
)}
)}
, host, ); }