/** * @file input-router.tsx — canvas pointer/keyboard classifier + hook * @scope apps/studio/input-router.tsx * @purpose Owned by canvas-lib's DesignCanvas. Classifies the NON-WHEEL * subset of pointer + key events into discrete router actions. * `useViewportController` keeps owning wheel + middle-mouse + * space-pan + Cmd+0/1/+/- — the two stacks coexist without a * listener race (DDR-026). * * Event ownership (read this before adding handlers): * * ┌──────────────────────────────────┬─────────────────────────┐ * │ Event │ Owner │ * ├──────────────────────────────────┼─────────────────────────┤ * │ wheel / shift-wheel / cmd-wheel │ useViewportController │ * │ pointerdown btn=1 / space-held │ useViewportController │ * │ keydown Space / Cmd+0/1/+/- │ useViewportController │ * │ pointermove (hover) │ input-router │ * │ pointerdown btn=0 (select) │ input-router │ * │ pointerdown btn=2 (right-click) │ input-router │ * │ keydown V / H / C / Esc │ input-router │ * └──────────────────────────────────┴─────────────────────────┘ * * The router does no DOM work itself — `classify()` is pure (testable without * a DOM) and `useInputRouter()` attaches listeners that dispatch through the * caller-supplied callbacks. Hover-target resolution + selection persistence * live in the consumer (DesignCanvas). */ import { type RefObject, useEffect } from 'react'; // ───────────────────────────────────────────────────────────────────────────── // Drag-vs-click threshold (T25) // // 4 px screen-pixel hypot separates "click" from "drag" — Microsoft Win32 // canonical (`SM_CXDRAG`/`SM_CYDRAG` default), also d3-drag and tldraw default. // Owned here so artboard-drag, artboard-marquee, element-marquee, annotation- // drag-vs-tap, and any future drag-class gesture all read the same constant. // Wheel + pinch-zoom are EXEMPT — threshold is for `pointerdown → pointermove` // drag classification only. export const DRAG_THRESHOLD_PX = 4; /** True once the pointer has moved ≥ DRAG_THRESHOLD_PX from its start. */ export function crossedDragThreshold( startX: number, startY: number, curX: number, curY: number ): boolean { const dx = curX - startX; const dy = curY - startY; return Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX; } // ───────────────────────────────────────────────────────────────────────────── // Types /** * Tool union. Phase 4.1 shipped V/H/C; Phase 5 adds the draw set * (pen / rect / arrow / eraser). Draw-tool pointer events are owned by * `AnnotationsLayer` — the router classifies their letter shortcuts but * returns `no-op` for the corresponding pointer events so the SVG overlay * can grab them natively. */ export type Tool = // feature-4 (browse/move split) — `browse` is the BOOT default: a pure // native pass-through so a freshly-opened mock stays ALIVE (buttons click, // links follow, inputs focus). It carries ZERO select machinery — the router // returns `no-op` for every browse-tool pointer event so descendants see them // untouched. Pressing V flips to `move` (the select tool). See DDR-187. | 'browse' | 'move' | 'hand' | 'comment' | 'pen' // Annotation polish (item 8) — FigJam-style highlighter. A `pen`-shaped tool // that produces a translucent wide multiply stroke (a PenStroke with // `highlighter:true`), not a new stroke type. | 'highlighter' // Phase 24 — `shape` is the single active draw tool that produces rect / // ellipse / polygon strokes (the kind is chosen via the palette popover). // `rect` / `ellipse` stay in the union as the stroke discriminants they // always were, but are no longer directly selectable active tools. | 'shape' | 'rect' | 'ellipse' | 'arrow' | 'sticky' | 'text' // FigJam v3 — labelled organizing container (Shift+S). | 'section' | 'eraser'; const ANNOTATION_TOOLS = new Set([ 'pen', 'highlighter', 'shape', 'rect', 'ellipse', 'arrow', 'sticky', 'text', 'section', 'eraser', ]); export function isAnnotationTool(t: Tool): boolean { return ANNOTATION_TOOLS.has(t); } export type RouterAction = | { kind: 'no-op' } | { kind: 'hover'; deep: boolean; clientX: number; clientY: number } | { kind: 'select'; /** `replace` swaps the selection set, `add` merges into it. */ mode: 'replace' | 'add'; /** * `true` resolves to the deepest descendant under the cursor (Cmd-held * mode). `false` resolves to the topmost interesting ancestor (top mode). * feature-4 (browse/move split, DDR-187) — the Move (select) tool now * fires the FULL Figma ladder: a BARE click selects the TOP-level object * (`deep:false`), Cmd selects the DEEPEST element (`deep:true`), Shift * adds, Cmd+Shift adds-deep. (Pre-feature-4 Move-tool select was * Cmd-only and always deep; bare clicks were passthrough — that role now * belongs to the `browse` tool, which never selects at all.) */ deep: boolean; clientX: number; clientY: number; } | { kind: 'drop-comment'; clientX: number; clientY: number } | { kind: 'context-menu'; clientX: number; clientY: number } | { kind: 'tool'; tool: Tool } | { kind: 'escape' } | { kind: 'undo' } | { kind: 'redo' }; export interface ClassifyInput { type: 'pointermove' | 'pointerdown' | 'contextmenu' | 'keydown'; /** PointerEvent.button: 0 = left, 1 = middle, 2 = right. */ button?: number; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean; key?: string; clientX?: number; clientY?: number; /** Spacebar held — shared signal with `useViewportController`'s pan-drag. */ spaceHeld?: boolean; /** Event target is editable (input/textarea/contentEditable) — caller computes. */ isEditable?: boolean; activeTool: Tool; } // ───────────────────────────────────────────────────────────────────────────── // classify — pure function. All branching lives here so unit tests cover every // row of the dispatch table without spinning up a DOM. const metaOrCtrl = (i: ClassifyInput): boolean => !!(i.metaKey || i.ctrlKey); export function classify(input: ClassifyInput): RouterAction { if (input.type === 'keydown') { if (input.isEditable) return { kind: 'no-op' }; // Tool letters are bare keys — Cmd/Ctrl/Alt+letter belongs to shell / browser. if (input.metaKey || input.ctrlKey || input.altKey) { // Esc with modifiers still dismisses. if (input.key === 'Escape') return { kind: 'escape' }; // Undo / redo (Phase 20). Alt is reserved — Cmd+Opt+Z is a browser // text-input gesture we don't claim. `metaKey || ctrlKey` covers both // mac and Windows / Linux without a platform sniff. const k = (input.key || '').toLowerCase(); if (!input.altKey && (input.metaKey || input.ctrlKey)) { if (k === 'z' && input.shiftKey) return { kind: 'redo' }; if (k === 'z') return { kind: 'undo' }; if (k === 'y' && !input.shiftKey) return { kind: 'redo' }; } return { kind: 'no-op' }; } const k = (input.key || '').toLowerCase(); if (k === 'v') return { kind: 'tool', tool: 'move' }; if (k === 'h') return { kind: 'tool', tool: 'hand' }; if (k === 'c') return { kind: 'tool', tool: 'comment' }; if (k === 'b') return { kind: 'tool', tool: 'pen' }; // I = hIghlighter (a free bare letter; 'H' is taken by Hand). if (k === 'i') return { kind: 'tool', tool: 'highlighter' }; // Phase 24 — R (and legacy O) both arm the single Shape tool; the specific // primitive is picked from the palette's shape-kind popover. if (k === 'r' || k === 'o') return { kind: 'tool', tool: 'shape' }; if (k === 'a') return { kind: 'tool', tool: 'arrow' }; // Phase 21 — N = sticky Note ('S' is taken by the shell Design-system view // + Shift-marquee); T = standalone Text. Both are bare letters the shell // yields when focus is inside the canvas iframe (app.jsx onKey bail). if (k === 'n') return { kind: 'tool', tool: 'sticky' }; if (k === 't') return { kind: 'tool', tool: 'text' }; // FigJam v3 — Shift+S arms the Section tool (FigJam's own binding; bare S // stays with the shell's Design-system view). Checked here because the // modifier guard above only filters Cmd/Ctrl/Alt. if (k === 's' && input.shiftKey) return { kind: 'tool', tool: 'section' }; if (k === 'e') return { kind: 'tool', tool: 'eraser' }; if (input.key === 'Escape') return { kind: 'escape' }; return { kind: 'no-op' }; } if (input.type === 'contextmenu') { return { kind: 'context-menu', clientX: input.clientX ?? 0, clientY: input.clientY ?? 0, }; } if (input.type === 'pointermove') { // Phase 5 draw tools: pen / rect / arrow / eraser own all their pointer // events through `AnnotationsLayer`. The router never paints a hover halo // while drawing — that affordance is reserved for select / comment. if (isAnnotationTool(input.activeTool)) return { kind: 'no-op' }; // Hand tool: drag pan is owned by useViewportController; no hover paint. if (input.activeTool === 'hand') return { kind: 'no-op' }; // Browse tool (feature-4 boot default): pass-through — no hover halo, // native interactions flow. Cmd-held hover previews the deepest element // (the escape-hatch select affordance, mirroring the old move-tool // behavior); bare hover stays native. if (input.activeTool === 'browse') { if (!metaOrCtrl(input)) return { kind: 'no-op' }; return { kind: 'hover', deep: true, clientX: input.clientX ?? 0, clientY: input.clientY ?? 0, }; } // Comment tool: always paint a preview halo on the deepest element under // cursor — that's the element the user is about to comment on. Comment // pin attachment is to the same element they were hovering. if (input.activeTool === 'comment') { return { kind: 'hover', deep: true, clientX: input.clientX ?? 0, clientY: input.clientY ?? 0, }; } // Move (select) tool: paint a preview halo of exactly what a click would // select — the TOP-level object on a bare hover (Figma's hover outline), // the DEEPEST element while Cmd is held (deep-select preview). return { kind: 'hover', deep: metaOrCtrl(input), clientX: input.clientX ?? 0, clientY: input.clientY ?? 0, }; } if (input.type === 'pointerdown') { if (input.button === 2) { return { kind: 'context-menu', clientX: input.clientX ?? 0, clientY: input.clientY ?? 0, }; } if (input.button === 1 || input.spaceHeld) return { kind: 'no-op' }; if (input.button !== 0) return { kind: 'no-op' }; // Phase 5 draw tools own bare left-clicks; the router returns no-op so // the SVG layer's own listeners (no preventDefault) fire normally. Cmd- // modified clicks still flow into the move-tool select path below — that // stays available as an escape hatch even while a draw tool is active. if (isAnnotationTool(input.activeTool) && !metaOrCtrl(input)) { return { kind: 'no-op' }; } if (input.activeTool === 'comment') { // Comment tool: bare click drops a pin. Cmd / Shift modifiers reserved // for future "scope comment to deepest" variants — for now they fall // through to the same drop. return { kind: 'drop-comment', clientX: input.clientX ?? 0, clientY: input.clientY ?? 0, }; } // Hand tool: pan is owned by useViewportController via `isPanDragActive`. // Router returns no-op so it doesn't preventDefault or stopPropagation — // the controller's pointerdown listener on the same host claims the drag. if (input.activeTool === 'hand') return { kind: 'no-op' }; // Browse tool (feature-4 boot default): bare/Shift left-clicks pass // through so the mock stays alive (a button press fires, a link follows, // an input focuses). Cmd/Ctrl+click is the ESCAPE HATCH (user steer // 2026-07-19): it selects the deepest element AND the consumer flips the // tool to Move — "I clicked to edit" shouldn't require pressing V first. if (input.activeTool === 'browse') { if (!metaOrCtrl(input)) return { kind: 'no-op' }; return { kind: 'select', mode: input.shiftKey ? 'add' : 'replace', deep: true, clientX: input.clientX ?? 0, clientY: input.clientY ?? 0, }; } // Move (select) tool. feature-4 (DDR-187) — the full Figma ladder: // bare click → select TOP-level object (deep:false, replace) // Shift+click → add TOP-level object (deep:false, add) // Cmd/Ctrl+click → select DEEPEST element (deep:true, replace) // Cmd+Shift+click → add DEEPEST element (deep:true, add) // Bare clicks are claimed (preventDefault) so native canvas interactions do // NOT fire in select mode — that's the browse tool's job. The click-vs-drag // threshold (canvas-shell.tsx ReorderDrag / marquee overlays, all still // gated on `tool === 'move'`) means a press that turns into a drag reorders // / marquees instead; a release-in-place is the select. const cmd = metaOrCtrl(input); const shift = !!input.shiftKey; return { kind: 'select', mode: shift ? 'add' : 'replace', deep: cmd, clientX: input.clientX ?? 0, clientY: input.clientY ?? 0, }; } return { kind: 'no-op' }; } // ───────────────────────────────────────────────────────────────────────────── // useInputRouter — attach listeners scoped to `hostRef.current`. Dispatches // through `callbacks`. Returns nothing; cleans up on unmount. export interface RouterCallbacks { onHover?: (a: Extract) => void; onSelect?: (a: Extract) => void; onDropComment?: (a: Extract) => void; onContextMenu?: (a: Extract) => void; onTool?: (a: Extract) => void; onEscape?: () => void; /** Phase 20 — Cmd+Z / Ctrl+Z. */ onUndo?: () => void; /** Phase 20 — Cmd+Shift+Z / Ctrl+Y / Cmd+Y. */ onRedo?: () => void; } export interface UseInputRouterOptions { hostRef: RefObject; /** Latest active tool — read at event time, not captured. */ getActiveTool: () => Tool; /** Optional spacebar-held signal shared with useViewportController. */ isSpaceHeld?: () => boolean; callbacks: RouterCallbacks; /** When false, listeners are not attached. Defaults to true. */ enabled?: boolean; /** * Allowlist of action kinds this router is permitted to CLAIM (preventDefault * + stopImmediatePropagation + dispatch). Any classified action outside the * set is downgraded to `no-op` so it propagates untouched to other listeners. * Omit to claim everything (the default — used by the full DesignCanvas * router). The shell-owned comment mount layer passes a narrow set so it can * coexist as an ANCESTOR capture-listener over a UI canvas's own router * without swallowing select / context-menu / undo gestures it doesn't own. */ claimableActions?: ReadonlySet; } export function isEditableTarget(t: EventTarget | null): boolean { if (!t || !(t as HTMLElement).tagName) return false; const el = t as HTMLElement; const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if (el.isContentEditable) return true; // Dogfood fix — `.isContentEditable` is a computed/inherited property whose // handling of the `contenteditable="plaintext-only"` token (the value the // element-text-edit system uses, canvas-shell.tsx) has had cross-engine/ // version inconsistencies. Check the raw attribute too so a tool-letter // shortcut (R, T, N, …) can never fire while that editor has focus, // regardless of whether isContentEditable correctly reflects it in a given // runtime — this was the reported bug (typing "R" while editing in-canvas // text switched to the Rectangle tool). const raw = el.getAttribute?.('contenteditable'); if (raw === 'true' || raw === 'plaintext-only' || raw === '') return true; return false; } /** * Phase 6 — the comments overlay (pins / composer / thread popover / mention * popup) lives INSIDE the canvas world, which means its DOM nodes are inside * the input-router's capture host. Without an explicit bail-out the router * would `preventDefault + stopImmediatePropagation` every click on a * composer button while comment mode is active, blocking Save / Cancel. * * We treat the overlay nodes like editable form widgets — the router yields, * the React event handler runs. */ export function isOverlayTarget(t: EventTarget | null): boolean { if (!t || !(t as Element).closest) return false; const el = t as Element; // Security review (issue-90) — `.dc-world` is exactly the subtree the // active canvas's own JSX renders into (`canvas-lib.tsx`'s // `
{children}
`); every selector below is // shell-owned chrome that NEVER renders inside it (ToolPalette/ // AnnotationsLayer/ContextMenuView are siblings of `.dc-canvas`, // DCZoomToolbar/DCMiniMap are children of `.dc-canvas` but siblings of // `.dc-world`). Since untrusted, AI/user-authored canvas content is the one // thing that DOES render inside `.dc-world`, it could otherwise spoof one // of these class names on its own element to make the router yield to it // (skip `preventDefault`) instead of claiming the gesture — bail out first // so a same-name imposter inside the canvas never qualifies. if (el.closest('.dc-world')) return false; // [data-mediaref-player] — the inline