/** * @file comments-overlay.tsx — FigJam-style in-place comments overlay * @scope apps/studio/comments-overlay.tsx * @purpose Renders DS-styled comment pins (Phase 6 Task 2), the in-place * composer bubble (Task 3), and the thread popover (Task 4) * inside the canvas iframe. Sibling to `annotations-layer`, but * NOT portaled into `.dc-world` — renders as a screen-coord * `position: fixed` layer instead (DDR-034; see "Pin position * math" below). * * Data flow (Phase 6 Task 2 — pins only; composer + thread land in Task 3/4): * 1. Shell (`client/app.jsx`) pushes `{ dgn: 'comments-set', comments }` * into the iframe whenever its `commentsByFile[activePath]` changes. * 2. Overlay also fetches `/_comments?file=...` on mount as a self-heal — * lets the overlay render even if the shell hasn't broadcast yet * (race on first iframe load). * 3. Shell pushes `{ dgn: 'comment-focus', id }` when the user clicks a row * in the comments panel — overlay highlights the matching pin. * 4. Overlay posts `{ dgn: 'comment-click', id }` back to the shell when * the user clicks a pin — same channel the legacy `dgn-pin` overlay * used; the shell already routes it to `setFocusedCommentId`. * * Filter respect — Phase 6 Task 2 default is "hide resolved". The shell will * gain a `comments-filter` channel in Task 6; until then the overlay always * hides resolved pins. Plan-aligned. * * Pin position math — see `resolveCommentTarget` below. Screen coords come * straight from `getBoundingClientRect()` on the live target; CSS zoom on the * world plane is already baked into that rect, so no zoom math is needed here. * * Target resolution + orphan cleanup — a canvas rewrite (`/design:edit` * regenerating JSX) renumbers `data-cd-id` (DDR-019's documented AST-position * trade-off), which can silently reanchor a comment to the wrong element or to * nothing. `resolveCommentTarget` tries the stored selector first, falls back * to a structural match via `resolveByDomPath` (dom-selection.ts) when the * direct hit is missing or looks like the wrong element (tag mismatch), and — * per DDR-034's deferred future-work item — `CommentPin` auto-deletes a * comment whose target stays unresolvable past a short grace window. * * Popup placement — `CommentComposer` / `CommentThread` pick a side * (left/right, above/below the anchor) that actually fits the viewport via * `placeNearPoint`, instead of always growing down-right (which used to clip * off-screen near the canvas edge). * * The legacy vanilla-JS `#dgn-pin-layer` injected by `inspect.ts` is hidden * on mount to avoid double-pins inside TSX canvases. The legacy layer still * renders for `.html` mocks where this React overlay never mounts. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { resolveByDomPath } from './dom-selection.ts'; import { isReadOnlyCanvas } from './read-only-mode.ts'; import { useCollab } from './use-collab.tsx'; import { useSelectionSetOptional } from './use-selection-set.tsx'; // ───────────────────────────────────────────────────────────────────────────── // Types — kept in sync with `Comment` in `api.ts`. We mirror the shape rather // than import to avoid pulling server types into the canvas runtime bundle. interface OverlayBounds { x: number; y: number; w: number; h: number; } // Selection payload posted by canvas-shell's `onDropComment`. Mirrors the // shape `hoverTargetToSelection` returns; we keep it loose so the overlay // doesn't depend on canvas-shell types. interface ComposeSelection { file?: string; id?: string; selector: string; artboardId?: string | null; /** Occurrence index within `selector` — always set by buildComposeSelection, * read into the comment payload below. Missing from this local mirror of * dom-selection's `Selection`, so the payload was silently sending * `undefined` to the checker's eyes and the real value at runtime. */ index: number; tag: string; classes: string; text: string; dom_path: string[]; bounds: OverlayBounds | null; html: string; } interface ComposerState { selection: ComposeSelection; clientX: number; clientY: number; } interface OverlayReply { id: string; author: string; body: string; created: string; } export interface OverlayComment { id: string; file: string; selector: string; /** Occurrence index among `querySelectorAll(selector)` — disambiguates a * component repeated within one artboard. Absent (old comments) → first. */ index?: number; bounds: OverlayBounds | null; text: string; status: 'open' | 'resolved'; created: string; resolved_at: string | null; author?: string; thread?: OverlayReply[]; mentions?: string[]; /** Target's tag/classes/ancestor-path at creation time — unused for * rendering, but the structural-fallback ingredients `resolveCommentTarget` * reaches for when the stored `selector` no longer identifies the right * element (see file header). Absent on legacy comments. */ tag?: string; classes?: string; dom_path?: string[]; // html_excerpt unused at overlay layer; kept off the type to keep the // surface tight. } // ───────────────────────────────────────────────────────────────────────────── // CSS load — sibling stylesheet, fetched once per session via a tag. // Inlining the file at build time would cost an extra bundler config; the // overlay is internal-only so a runtime is fine. const CSS_HREF = '/_client/comments-overlay.css'; function ensureOverlayStyles(): void { if (typeof document === 'undefined') return; if (document.getElementById('cm-overlay-css')) return; const link = document.createElement('link'); link.id = 'cm-overlay-css'; link.rel = 'stylesheet'; link.href = CSS_HREF; document.head.appendChild(link); } // ───────────────────────────────────────────────────────────────────────────── // File derivation — same logic as canvas-shell.tsx::deriveFile(). Duplicated // here so the overlay can fetch its own comments on mount without importing // from canvas-shell (which would create a cycle). function deriveFile(): string | null { if (typeof window === 'undefined') return null; try { const p = window.location.pathname; if (p === '/_canvas-shell.html' || p === '/_canvas-shell') { const qs = new URLSearchParams(window.location.search); const canvas = qs.get('canvas') ?? ''; const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, ''); return canvas ? `${designRel}/${canvas}` : null; } return decodeURIComponent(p).replace(/^\//, ''); } catch { return null; } } // ───────────────────────────────────────────────────────────────────────────── // Position resolvers — screen coords via getBoundingClientRect. Mirrors the // `SelectionHalos` / `HoverHalo` pattern in canvas-shell.tsx so the comments // layer can render as a fixed-position sibling of `.dc-canvas` (above the // halo chrome at z-index 5) instead of being portaled into `.dc-world` where // it would lose the stacking battle. export interface TargetRef { selector: string; index?: number; tag?: string; classes?: string; dom_path?: string[]; } /** * Resolve a comment (or in-progress selection)'s live target element. Tries * the stored `data-cd-id` selector first — cheap, correct in the common case. * A tag mismatch against what was captured at creation time is the tell that * `data-cd-id` renumbered onto an unrelated element (DDR-019); when that * happens, or the selector matches nothing at all, fall back to a structural * match via `resolveByDomPath`. */ export function resolveCommentTarget(target: TargetRef): HTMLElement | null { if (!target.selector) return null; let el: HTMLElement | null = null; try { // index disambiguates a component repeated within one artboard (querySelector // alone would always grab the first match). Absent/0 → first. const all = document.querySelectorAll(target.selector); const i = target.index && target.index > 0 && target.index < all.length ? target.index : 0; el = (all[i] ?? all[0] ?? null) as HTMLElement | null; } catch { el = null; } if (el && target.tag && el.tagName.toLowerCase() !== target.tag.toLowerCase()) { el = null; } if (!el && target.dom_path?.length) { const artboardId = target.selector.match(/data-dc-screen="([^"]+)"/)?.[1]; el = resolveByDomPath(document, { artboardId, tag: target.tag, classes: target.classes, dom_path: target.dom_path, }) as HTMLElement | null; } return el; } function screenRectFor(target: TargetRef): { x: number; y: number; w: number; h: number; } | null { const el = resolveCommentTarget(target); if (!el?.isConnected) return null; const r = el.getBoundingClientRect(); if (r.width === 0 && r.height === 0) return null; return { x: r.left, y: r.top, w: r.width, h: r.height }; } // ───────────────────────────────────────────────────────────────────────────── // Edge-aware popup placement — picks a side (left/right, above/below the // anchor point) that actually fits the viewport, falling back to an inward // clamp for the rare case where no side fully fits (tiny viewport). Mirrors // the pattern context-menu.tsx already uses for the same problem, but flips // axes instead of only clamping, per the explicit ask: always open toward // whichever side has room, not just "shifted back into view". export function placeNearPoint( point: { x: number; y: number }, size: { w: number; h: number } ): { x: number; y: number } { const margin = 8; const vw = typeof window !== 'undefined' ? window.innerWidth : point.x + size.w; const vh = typeof window !== 'undefined' ? window.innerHeight : point.y + size.h; let x = point.x; if (x + size.w + margin > vw) { const flipped = point.x - size.w; x = flipped >= margin ? flipped : Math.max(margin, vw - size.w - margin); } let y = point.y; if (y + size.h + margin > vh) { const flipped = point.y - size.h; y = flipped >= margin ? flipped : Math.max(margin, vh - size.h - margin); } return { x, y }; } // ───────────────────────────────────────────────────────────────────────────── // Public component — mounted from canvas-shell.tsx alongside ToolPalette / // AnnotationsLayer / SnapGuideOverlay. export function CommentsOverlay(): React.ReactNode { ensureOverlayStyles(); // Optional — CommentsOverlay is mounted inside SelectionSetProvider in the // standard CanvasShell tree, but stays usable if a host ever embeds it // outside that provider (returns null instead of throwing). const selSet = useSelectionSetOptional(); const [comments, setComments] = useState([]); const [focusedId, setFocusedId] = useState(null); const [composer, setComposer] = useState(null); const file = useMemo(() => deriveFile(), []); // Drop the legacy `#dgn-pin-layer` so we don't render duplicate pins inside // TSX canvases. The layer ships in every served HTML page via inspect.ts; // for `.html` mocks (no canvas-shell mount) it still does its job. useEffect(() => { if (typeof document === 'undefined') return; const legacy = document.getElementById('dgn-pin-layer') as HTMLElement | null; if (!legacy) return; const prev = legacy.style.display; legacy.style.display = 'none'; return () => { legacy.style.display = prev; }; }, []); // Mirror a comment into the canvas selection set so SelectionHalos paints // the same halo Cmd-click would. Called from both the in-iframe pin click // AND the inbound `comment-focus` postMessage so jumping from the shell's // Comments panel produces the same visual feedback as clicking the pin. const mirrorSelection = useCallback( (comment: OverlayComment | undefined) => { if (!selSet) return; if (!comment?.selector) { selSet.clear(); return; } const cdMatch = comment.selector.match(/data-cd-id="([^"]+)"/); const cdId = cdMatch ? cdMatch[1] : undefined; let tag: string | undefined; let classes: string | undefined; try { const el = document.querySelector(comment.selector) as HTMLElement | null; if (el) { tag = el.tagName.toLowerCase(); classes = (el.getAttribute('class') ?? '') .split(/\s+/) .filter((cls) => cls && !cls.startsWith('dgn-') && !cls.startsWith('dc-cv-')) .join(' '); } } catch { /* unresolvable selector — fall through with no fresh metadata */ } selSet.replace({ file: file ?? undefined, id: cdId, selector: comment.selector, tag, classes, bounds: comment.bounds ?? undefined, }); }, [selSet, file] ); // Keep the latest comments list reachable from the message handler without // re-attaching the listener on every comments mutation. const commentsRef = useRef(comments); commentsRef.current = comments; // Phase 8 Task 3 — when a collab room is connected, the Y.Array of comments // is the live source of truth. observe() fires on every remote mutation // (added pins from another tab, resolved-from-inspector via the registry // bridge, etc.) and on the local seed. Both paths converge on the same // JSON projection — last-write-wins between Y.Array and postMessage is // safe because they carry identical content; the Y.Array path just // reaches us first (no 800 ms debounce delay). const collab = useCollab(); // Who is looking — the same identity a new comment is signed with (the // project's git user on a desktop, the vouched member in a cloud cell). // Asked directly: this overlay also runs in the comment-mount bundle, where // the collab context is another module's and reads as null. const me = useViewerName(); useEffect(() => { if (!collab) return; const arr = collab.doc.getArray('comments'); const sync = () => { // toArray() snapshot the current Y.Array into a plain JS list. setComments(arr.toArray() as OverlayComment[]); }; // Initial fill — covers the case where Y.Doc was already seeded by the // time this overlay mounted. if (arr.length > 0) sync(); arr.observe(sync); return () => { try { arr.unobserve(sync); } catch { /* doc destroyed before unmount — observer already gone */ } }; }, [collab]); // Listen for the shell's broadcast channels. Schema matches the legacy // overlay so the shell-side glue in client/app.jsx (~line 1672) keeps // working without modification. useEffect(() => { if (typeof window === 'undefined') return; const onMessage = (e: MessageEvent) => { const m = e.data as { dgn?: string; comments?: unknown; id?: string } | null; if (!m || typeof m !== 'object' || !m.dgn) return; if (m.dgn === 'comments-set' && Array.isArray(m.comments)) { setComments(m.comments as OverlayComment[]); } else if (m.dgn === 'comment-focus') { const id = typeof m.id === 'string' ? m.id : null; setFocusedId(id); const target = id ? commentsRef.current.find((c) => c.id === id) : undefined; mirrorSelection(target); } }; window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [mirrorSelection]); // canvas-shell's `onDropComment` dispatches `cm:open-composer` on the iframe // document. Open the composer pinned to that click point. useEffect(() => { if (typeof document === 'undefined') return; const onOpen = (e: Event) => { const detail = ( e as CustomEvent<{ selection?: ComposeSelection; clientX?: number; clientY?: number }> ).detail; if (!detail?.selection) return; setComposer({ selection: detail.selection, clientX: typeof detail.clientX === 'number' ? detail.clientX : 0, clientY: typeof detail.clientY === 'number' ? detail.clientY : 0, }); }; document.addEventListener('cm:open-composer', onOpen); return () => document.removeEventListener('cm:open-composer', onOpen); }, []); const closeComposer = useCallback(() => { setComposer(null); if (typeof window === 'undefined') return; try { window.parent.postMessage({ dgn: 'force-clear' }, '*'); } catch { /* parent detached */ } }, []); const submitComposer = useCallback( (text: string) => { if (!composer) return; const sel = composer.selection; const payload = { file: sel.file, selector: sel.selector, index: sel.index, dom_path: sel.dom_path, tag: sel.tag, classes: sel.classes, bounds: sel.bounds, html_excerpt: sel.html, text, }; if (typeof window === 'undefined') return; // Shell relays into the WS `comments-add` channel and persists. try { window.parent.postMessage({ dgn: 'comment-submit', payload }, '*'); } catch { /* parent detached */ } closeComposer(); }, [composer, closeComposer] ); // Self-heal fetch — covers the race where the iframe loads before the shell // pushes `comments-set` (e.g. first hydration on cold open). useEffect(() => { if (!file) return; let cancelled = false; (async () => { try { const r = await fetch(`/_comments?file=${encodeURIComponent(file)}`); if (!r.ok) return; const data = (await r.json()) as { comments?: OverlayComment[] }; if (cancelled) return; if (Array.isArray(data.comments)) { // Only set when we haven't received a shell broadcast yet; the // shell is authoritative once it kicks in. setComments((prev) => (prev.length === 0 ? (data.comments ?? []) : prev)); } } catch { /* offline / dev-server restart — silently no-op */ } })(); return () => { cancelled = true; }; }, [file]); // Sorted by `created` asc so sequence numbers are stable per canvas across // reloads. Resolved comments are hidden by default (Task 2 spec). const visible = useMemo(() => { const list = comments.slice().sort((a, b) => a.created.localeCompare(b.created)); return list.filter((c) => c.status !== 'resolved'); }, [comments]); // Sequence index lookup — built off the FULL sorted list so a resolved-then- // reopened pin keeps its original number. const indexById = useMemo(() => { const m = new Map(); const all = comments.slice().sort((a, b) => a.created.localeCompare(b.created)); all.forEach((c, i) => { m.set(c.id, i + 1); }); return m; }, [comments]); const handlePinClick = useCallback( (id: string) => { setFocusedId(id); mirrorSelection(comments.find((c) => c.id === id)); if (typeof window === 'undefined') return; try { window.parent.postMessage({ dgn: 'comment-click', id }, '*'); } catch { /* parent detached */ } }, [comments, mirrorSelection] ); const handlePatch = useCallback((id: string, patch: Record) => { if (typeof window === 'undefined') return; try { window.parent.postMessage({ dgn: 'comment-patch', id, patch }, '*'); } catch { /* parent detached */ } }, []); const handleDelete = useCallback((id: string) => { if (typeof window === 'undefined') return; try { window.parent.postMessage({ dgn: 'comment-delete', id }, '*'); } catch { /* parent detached */ } setFocusedId((prev) => (prev === id ? null : prev)); }, []); const handleReply = useCallback(async (id: string, body: string): Promise => { if (typeof fetch === 'undefined') return false; try { const r = await fetch(`/_api/comments/${encodeURIComponent(id)}/reply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body }), }); if (!r.ok) return false; const updated = (await r.json()) as OverlayComment; // Optimistic local merge — the shell will broadcast `comments-set` // shortly after as the WS fans out the change, but applying it now // avoids the popover flickering empty between submit + broadcast. setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); return true; } catch { return false; } }, []); return (
{visible.map((c) => { const n = indexById.get(c.id) ?? 0; return ( ); })} {composer ? ( ) : null} {(() => { if (!focusedId) return null; const focused = visible.find((c) => c.id === focusedId); if (!focused) return null; return ( { setFocusedId(null); // Drop the canvas halo when the thread closes — symmetric with // `handlePinClick` which paints it on open. selSet?.clear(); }} onPatch={(patch) => handlePatch(focused.id, patch)} onDelete={() => handleDelete(focused.id)} onReply={(body) => handleReply(focused.id, body)} mine={ !isReadOnlyCanvas() && !!(me ?? collab?.myName) && (focused.author ?? '').trim() === (me ?? collab?.myName ?? '').trim() } /> ); })()}
); } // ───────────────────────────────────────────────────────────────────────────── // MentionAwareTextarea + popup — Task 5 // // Wraps a textarea and surfaces an autocomplete popup when the caret sits // inside an `@` token (no whitespace between `@` and cursor). The // committer list is fetched once on first focus and cached for the session. // Keyboard: ↑↓ move, ↵/Tab insert (`@firstname `), Esc dismiss. interface CommitterRow { name: string; email: string; commits: number; } let committerCache: Promise | null = null; async function loadCommitters(): Promise { if (!committerCache) { committerCache = (async () => { try { const r = await fetch('/_api/git-committers'); if (!r.ok) return []; const data = (await r.json()) as { committers?: CommitterRow[] }; return Array.isArray(data.committers) ? data.committers : []; } catch { return []; } })(); } return committerCache; } function firstNameSlug(name: string): string { // `@firstname` is what we insert on accept. Strip surnames + punctuation. const first = name.trim().split(/\s+/)[0] ?? ''; // Keep alphanum + `.` `-` `_` (matches the parseMentions regex on the server). return first.replace(/[^\w.-]/g, '').toLowerCase(); } interface MentionToken { start: number; // index of `@` end: number; // exclusive — current caret query: string; // chars between `@` and caret (excluding `@`) } function detectMentionToken(text: string, caret: number): MentionToken | null { if (caret <= 0 || caret > text.length) return null; // Walk backwards from caret; the token starts at `@` and ends at the caret. // Aborts on whitespace, newline, or any non-mention char so a stray `@` in // an email is ignored. let i = caret - 1; while (i >= 0) { const ch = text[i] ?? ''; if (ch === '@') { // Token must be word-leading: previous char is start-of-string or whitespace. const prev = i > 0 ? text[i - 1] : ''; if (i === 0 || /\s/.test(prev ?? '')) { const query = text.slice(i + 1, caret); return { start: i, end: caret, query }; } return null; } if (!/[\w.-]/.test(ch)) return null; i -= 1; } return null; } function MentionAwareTextarea({ className, value, onChange, onKeyDown, placeholder, rows, disabled, textareaRef, ariaLabel, }: { className: string; value: string; onChange: (next: string) => void; onKeyDown?: (e: React.KeyboardEvent) => void; placeholder?: string; rows?: number; disabled?: boolean; textareaRef?: React.MutableRefObject; ariaLabel?: string; }): React.ReactElement { const internalRef = useRef(null); const setRef = useCallback( (el: HTMLTextAreaElement | null) => { internalRef.current = el; if (textareaRef) textareaRef.current = el; }, [textareaRef] ); const [committers, setCommitters] = useState([]); const [token, setToken] = useState(null); const [highlight, setHighlight] = useState(0); // Lazy-load committers on first focus. const onFocus = useCallback(() => { if (committers.length > 0) return; void loadCommitters().then((list) => setCommitters(list)); }, [committers.length]); const filtered = useMemo(() => { if (!token) return [] as CommitterRow[]; const q = token.query.toLowerCase(); const list = !q ? committers : committers.filter( (c) => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) ); return list.slice(0, 8); }, [token, committers]); const refreshToken = useCallback((textarea: HTMLTextAreaElement) => { const caret = textarea.selectionStart ?? textarea.value.length; const t = detectMentionToken(textarea.value, caret); setToken(t); setHighlight(0); }, []); const handleChange = useCallback( (e: React.ChangeEvent) => { onChange(e.target.value); refreshToken(e.target); }, [onChange, refreshToken] ); const insertMention = useCallback( (committer: CommitterRow) => { if (!token) return; const ta = internalRef.current; if (!ta) return; const tag = `@${firstNameSlug(committer.name)}`; const next = `${value.slice(0, token.start)}${tag} ${value.slice(token.end)}`; onChange(next); setToken(null); // Restore caret just past the inserted token + trailing space. const newCaret = token.start + tag.length + 1; requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(newCaret, newCaret); }); }, [token, value, onChange] ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (token && filtered.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => (h + 1) % filtered.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => (h - 1 + filtered.length) % filtered.length); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); const pick = filtered[highlight] ?? filtered[0]; if (pick) insertMention(pick); return; } if (e.key === 'Escape') { e.preventDefault(); setToken(null); return; } } onKeyDown?.(e); }, [token, filtered, highlight, insertMention, onKeyDown] ); const handleSelect = useCallback( (e: React.SyntheticEvent) => { refreshToken(e.currentTarget); }, [refreshToken] ); return (