/** * @file canvas-comment-mount.tsx — shell-owned comment layer + mountCanvas * @scope apps/studio/canvas-comment-mount.tsx * @purpose The canvas mount harness (`_shell.html`) calls `mountCanvas` * instead of rendering the canvas default-export raw. mountCanvas * wraps ANY default export — a `DesignCanvas` UI canvas OR a bare * DS specimen — in a LITE comment provider tree so the in-place * comment tool works on every mounted surface. * * Why a single shell-owned layer (DDR — see plan §"Key decision"): * - Comments used to be mounted only by `DesignCanvas` (ToolProvider + * SelectionSetProvider + CommentsOverlay + the onDropComment router * branch). Bare specimens (`system//preview/*.tsx`) never render * DesignCanvas, so they had no comment tool at all. * - Hoisting the comment subsystem here makes it universal. `DesignCanvas` * becomes a CONSUMER of the shell-provided ToolProvider / SelectionSet / * CommentsOverlay (via MaybeToolProvider / MaybeSelectionSetProvider and * by dropping its own ). * * Coexistence with the UI-canvas router: this layer's input router is an * ANCESTOR capture-listener over the canvas. On a UI canvas, `CanvasShell` * still runs its OWN router (hover / select / context-menu / undo). To avoid * swallowing those gestures, this router passes a narrow `claimableActions` * allowlist — `drop-comment` / `tool` / `escape` / `hover`. `hover` never * preventDefaults so the inner router's halo is unaffected; everything else * (select / context-menu / undo) propagates untouched to the inner router. */ import { Component, type ComponentType, createElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { createRoot } from 'react-dom/client'; import { CommentsOverlay } from './comments-overlay.tsx'; import { deriveFile, hoverTargetToSelection } from './dom-selection.ts'; import { type HoverTarget, isOverlayTarget, type RouterAction, resolveHoverTarget, useInputRouter, } from './input-router.tsx'; import { ElementResizeOverlay } from './use-element-resize.tsx'; import { MaybeSelectionSetProvider, type Selection, useSelectionSet, } from './use-selection-set.tsx'; import { MaybeToolProvider, useToolMode } from './use-tool-mode.tsx'; // ───────────────────────────────────────────────────────────────────────────── // Phase 24 — the comment-mode cursor (and every other tool cursor) is owned // SOLELY by use-tool-mode.tsx, whose `* { cursor: !important }` // rule is injected by the ToolProvider this layer mounts (MaybeToolProvider) — // so it covers bare DS specimens too. The old `[data-mc-host]…`/`body[…]` // comment-cursor rule used to live here, but its higher specificity shadowed // the unified Kenney cursor on UI canvases (comment-mount stamps // `data-active-tool` on , so `body[data-active-tool="comment"] *` matched // there as well). Removed so the single source of truth wins. See DDR-067. // ───────────────────────────────────────────────────────────────────────────── // CommentHost — owns the lite comment subsystem: the input router (comment- // scoped), the single CommentsOverlay, the comment-mode cursor attribute, and // the parent `dgn:'tool-set'` listener (so the outer menubar comment toggle // reaches the iframe). // Only these action kinds are claimed by the mount-layer router; the rest // propagate to a UI canvas's own router. `tool` + `escape` are also handled by // the inner router on UI canvases — idempotent (same shared provider). `hover` // is dispatched too (it never preventDefaults, so the inner router's own hover // halo on a UI canvas is unaffected) — we only PAINT the mount-layer preview // halo on a bare specimen, where there is no inner CanvasShell halo. const COMMENT_CLAIMS: ReadonlySet = new Set([ 'drop-comment', 'tool', 'escape', 'hover', ]); // True when no DesignCanvas/CanvasShell is mounted on this surface — i.e. a // bare DS specimen. On a UI canvas (`.dc-canvas` present) the inner shell owns // hover-halo painting + `resolveHoverTarget` element anchoring, so the lite // layer defers to it. export function isBareSpecimen(): boolean { return typeof document !== 'undefined' && !document.querySelector('.dc-canvas'); } // Deepest non-chrome element under a point — the comment anchor for a bare // specimen (specimens aren't stamped with `data-cd-id` and have no // `.dc-artboard-body`, so `resolveHoverTarget` returns null for them). function pickSpecimenEl(clientX: number, clientY: number): HTMLElement | null { if (typeof document === 'undefined') return null; const hit = document.elementFromPoint(clientX, clientY) as HTMLElement | null; if (!hit) return null; // Never anchor to comment chrome, the selection/resize overlay, or the root. if ( hit.closest( '.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, [data-mc-hover-halo], [data-mc-selection-halo], .dc-el-resize-handle' ) ) { return null; } const tag = hit.tagName; if (tag === 'HTML' || tag === 'BODY') return null; return hit; } // feature-element-editing-robustness Stage E — the SELECT anchor for a bare // specimen. Generalizes `pickSpecimenEl` (the comment anchor) by climbing to the // hit element's own `data-cd-id`, else its nearest stamped ancestor — every JSX // element the pipeline stamps unconditionally (canvas-pipeline.ts), so a // specimen element always resolves to a cd-id the Inspector's `edit-css`/ // `edit-attr` can target. Falls back to the bare hit when (defensively) nothing // is stamped, in which case the selection degrades to a `cssPath` selector. export function pickSpecimenSelectEl(clientX: number, clientY: number): HTMLElement | null { const hit = pickSpecimenEl(clientX, clientY); if (!hit) return null; const stamped = hit.closest('[data-cd-id]') as HTMLElement | null; return stamped ?? hit; } function CommentHost({ children, file }: { children: ReactNode; file: string | undefined }) { const { tool, setTool } = useToolMode(); const selSet = useSelectionSet(); const hostRef = useRef(null); // Hover-preview halo target (bare specimens only — see isBareSpecimen). const [hoverEl, setHoverEl] = useState(null); // Latest tool for the router (read at event time, not captured). const toolRef = useRef(tool); toolRef.current = tool; const getActiveTool = useMemo(() => () => toolRef.current, []); // Drop the preview halo whenever we leave comment mode. useEffect(() => { if (tool !== 'comment') setHoverEl(null); }, [tool]); // feature-element-editing-robustness Stage E — element SELECT on a bare // specimen. `selectedEl` drives the selection halo; `isSpecimen` gates the // resize overlay so it mounts on specimens ONLY (a UI canvas already mounts // its own inside CanvasShell — mounting a second here would double the handles). const [selectedEl, setSelectedEl] = useState(null); const [isSpecimen, setIsSpecimen] = useState(false); useEffect(() => { // The inner DesignCanvas (`.dc-canvas`) mounts a beat after this layer, so // re-check on the next frame + a short timeout before deciding. const check = () => setIsSpecimen(isBareSpecimen()); check(); const raf = requestAnimationFrame(check); const t = setTimeout(check, 120); return () => { cancelAnimationFrame(raf); clearTimeout(t); }; }, []); // Specimen select — a capture-phase Cmd/Ctrl-click. Self-gated on // `isBareSpecimen()` at event time: a UI canvas has its own CanvasShell select // router (specimens have none), so this is the ONLY select handler on a // specimen and a pure no-op on a UI canvas — no double-handling, no need to // touch the shared COMMENT_CLAIMS (which would preventDefault a UI canvas's // own select). `selSet.replace` posts `dgn:'select-set'` to the parent shell, // so the Inspector opens (Stage C) + edits persist via `edit-css` (Stage E3). useEffect(() => { if (typeof document === 'undefined') return; const onDown = (e: PointerEvent) => { if (e.button !== 0 || !(e.metaKey || e.ctrlKey)) return; // select gesture only if (!isBareSpecimen()) return; // UI canvas → CanvasShell owns select if (isOverlayTarget(e.target)) return; // comment chrome owns its clicks const el = pickSpecimenSelectEl(e.clientX, e.clientY); if (!el) return; e.preventDefault(); e.stopImmediatePropagation(); const cdId = el.getAttribute('data-cd-id'); const sel = hoverTargetToSelection({ el, cdId, artboardId: null } as HoverTarget); if (e.shiftKey) selSet.add(sel); else selSet.replace(sel); setSelectedEl(el); }; document.addEventListener('pointerdown', onDown, true); return () => document.removeEventListener('pointerdown', onDown, true); }, [selSet]); // Drop the selection halo when the selection clears (Esc / parent force-clear). useEffect(() => { if (selSet.selected.length === 0) setSelectedEl(null); }, [selSet.selected]); // Reflect the active tool onto the host (and body, since the host is // display:contents and can't carry a paintable cursor). Comment-mode CSS // keys off `[data-active-tool="comment"]`. useEffect(() => { const host = hostRef.current; if (host) host.setAttribute('data-active-tool', tool); if (typeof document !== 'undefined' && document.body) { document.body.setAttribute('data-active-tool', tool); } return () => { host?.removeAttribute('data-active-tool'); }; }, [tool]); // Parent `dgn:'tool-set'` — the outer dev-server menubar posts this when the // user toggles the comment tool. Mirrors canvas-shell's listener so the // toggle reaches bare specimens too (which have no inner shell listener). useEffect(() => { if (typeof window === 'undefined') return; const onMessage = (e: MessageEvent) => { const m = e.data as { dgn?: string; tool?: string } | null; if (!m || typeof m !== 'object' || m.dgn !== 'tool-set') return; if (typeof m.tool === 'string') setTool(m.tool as never); }; window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [setTool]); useInputRouter({ hostRef, getActiveTool, claimableActions: COMMENT_CLAIMS, callbacks: { onHover: ({ clientX, clientY }) => { // Paint a preview halo only on bare specimens; a UI canvas's own // CanvasShell HoverHalo owns the comment-mode preview there. if (toolRef.current !== 'comment' || !isBareSpecimen()) { setHoverEl(null); return; } const el = pickSpecimenEl(clientX, clientY); setHoverEl((prev) => (prev === el ? prev : el)); }, onTool: ({ tool: t }) => setTool(t), onEscape: () => { if (toolRef.current !== 'move') setTool('move'); setHoverEl(null); selSet.clear(); if (typeof window !== 'undefined') { try { window.parent.postMessage({ dgn: 'force-clear' }, '*'); } catch { /* parent detached */ } } }, onDropComment: ({ clientX, clientY }) => dropComment(clientX, clientY, selSet, file), }, }); // `display: contents` keeps the specimen's own flex/grid layout byte- // identical — the host box contributes nothing to layout. The fixed-position // CommentsOverlay renders fine as a child regardless. return (
{children} {hoverEl ? : null} {selectedEl ? : null} {isSpecimen ? : null}
); } // ───────────────────────────────────────────────────────────────────────────── // MountSelectionHalo — Stage E. A steadier accent outline around the SELECTED // specimen element (vs the lighter hover halo). rAF-follows the element's screen // box; inline-styled (a bare specimen doesn't load canvas-lib's HALO_CSS). function MountSelectionHalo({ el }: { el: HTMLElement }) { const ref = useRef(null); const targetRef = useRef(el); targetRef.current = el; const rafRef = useRef(null); useEffect(() => { const tick = () => { rafRef.current = null; const div = ref.current; const t = targetRef.current; if (div && t?.isConnected) { const r = t.getBoundingClientRect(); if (r.width === 0 && r.height === 0) { div.style.display = 'none'; } else { div.style.display = 'block'; div.style.left = `${Math.round(r.left)}px`; div.style.top = `${Math.round(r.top)}px`; div.style.width = `${Math.round(r.width)}px`; div.style.height = `${Math.round(r.height)}px`; } } else if (div) { div.style.display = 'none'; } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); }; }, []); return (