"use client"; /** * WhiteboardCanvas — hand-rolled zero-dep SVG renderer (A-001: OSS canvas * libs evaluated → rejected on dependency grounds; all geometry/ops come from * `@factory/whiteboard`). * * T-168: pan/zoom (wheel + pan tool), marquee selection, sticky + text * create/move/resize/delete, label rendering through SVG `` * (textContent — labels are DATA, never markup: the XSS test asserts an * `` label renders as inert text). * T-169: rect/ellipse drag-create, connectors (straight/orthogonal + arrow, * anchored via `nearestAnchor`), freehand capture (`decimatePoints`), * grouping/ungrouping (package ops — union bounds, dangling connectors die * with a removed child). * T-170: **undo/redo** (package `OpStack` — every edit is a pushed snapshot; * `⌘Z`/`⌘⇧Z`, bounded to 100 states), **save & restore** (explicit * "Save version" snapshot via `POST /pages/:id/versions` + restore a version * via the existing endpoint — the server now snapshots/restores the canvas * sidecar), **page comments** (the page's CommentPanel — comments are * page-scoped, so the drawer is mounted here), **collaboration indicators** * (the page channel the layout already opens for every page route — peers * render as avatars), and **links on elements** (optional `link` field: * internal page reference or http(s) URL — a click navigates / opens). * * An unvalidatable document (`valid:false`) renders read-only with a banner * (the roadmap's unknown-version contract). Editing model: the canvas is * immutable (`ops` return new documents); every gesture applies ops through * the history stack, and a debounced autosave PUTs the full document * (single-user v1 — A-004 — so last-write-wins is the contract). */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { addElement, anchorPoint, arrowHead, Canvas, CanvasElement, connectorPath, decimatePoints, displayBounds, emptyCanvas, groupElements, hitTest, moveMany, nearestAnchor, nextZ, OpStack, rectsIntersect, removeElement, screenToWorld, ungroupElement, updateElement, viewportToWorldRect, type Anchor, type ElementLink, type Viewport } from "@factory/whiteboard"; import { whiteboardApi, type WhiteboardCanvasResponse } from "@/lib/api"; import { usePageTree, usePageVersions } from "@/hooks/use-pages"; import { useBlockPatchStore } from "@/stores/block-patch-store"; import { useAuthStore } from "@/lib/auth-store"; import { useWhiteboardCanvas, useWhiteboardSave, whiteboardKey } from "@/hooks/use-whiteboard"; import { toast } from "@/stores/ui-store"; import { CommentPanel } from "@/components/comments/comment-panel"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { X, Undo2, Redo2, History, MessageSquare, Link2, FilePlus2 } from "lucide-react"; import type { Presence } from "@/lib/types"; /** Sticky palette keys → render colors (the schema owns the key set). */ const STICKY_HEX: Record = { yellow: "#FEF3C7", blue: "#DBEAFE", green: "#DCFCE7", pink: "#FCE7F3", purple: "#EDE9FE", orange: "#FFEDD5" }; const SELECTION = "#2563EB"; const MIN_SIZE = 10; /** Undo/redo bound (the OpStack capacity; 100 canvas states). */ const HISTORY_CAP = 100; /** How many versions the restore list shows (newest first). */ const VERSION_LIMIT = 20; type Tool = "select" | "pan" | "sticky" | "text" | "rect" | "ellipse" | "connector" | "pen"; interface Preview { kind: "shape" | "connector" | "pen"; shape?: "rect" | "ellipse"; a: { x: number; y: number }; b: { x: number; y: number }; points?: number[]; fromEl?: { elementId: string; anchor: Anchor }; } interface DragState { mode: "pan" | "move" | "resize" | "marquee" | "create-shape" | "connector" | "pen"; startWorld: { x: number; y: number }; /** For move: the element ids. For resize: the element id. */ ids?: string[]; /** For create-shape: which shape kind was started. */ shape?: "rect" | "ellipse"; /** For connector: the endpoint element the drag started on. */ fromEl?: { elementId: string; anchor: Anchor }; /** For pen: the accumulating world points. */ points?: number[]; startScreen: { x: number; y: number }; startView: Viewport; moved: boolean; } function newId(prefix: string): string { return `${prefix}_${Math.random().toString(36).slice(2, 12)}`; } function normRect(a: { x: number; y: number }, b: { x: number; y: number }): { x: number; y: number; w: number; h: number } { return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) }; } /** A flat `[x0,y0,x1,y1,…]` array → an SVG `points` attribute. */ function pointsAttr(points: number[]): string { const out: string[] = []; for (let i = 0; i + 1 < points.length; i += 2) { const x = points[i]; const y = points[i + 1]; if (x === undefined || y === undefined) break; out.push(`${x},${y}`); } return out.join(" "); } /** Word-wrap `text` into SVG-friendly lines at an approximate char width. */ function wrapLines(text: string, width: number, fontSize: number, maxLines: number): string[] { const cpl = Math.max(4, Math.floor(width / (fontSize * 0.58))); const out: string[] = []; for (const raw of text.split("\n")) { let line = raw; while (line.length > cpl) { let cut = line.lastIndexOf(" ", cpl); if (cut < cpl * 0.5) cut = cpl; out.push(line.slice(0, cut).trimEnd()); line = line.slice(cut).trimStart(); } out.push(line); if (out.length >= maxLines) break; } if (out.length >= maxLines && text.length > out.join("").length) out[maxLines - 1] = (out[maxLines - 1] ?? "") + "…"; return out.slice(0, maxLines); } /** Label text rendered as SVG `` — React escapes children, so a label * like `` is inert text (never parsed as markup). */ function Label({ x, y, w, text, fontSize, fill, anchor }: { x: number; y: number; w: number; text: string; fontSize: number; fill: string; anchor: "start" | "middle" }) { const maxLines = Math.max(1, Math.floor(40 / fontSize)); const lines = wrapLines(text, w, fontSize, maxLines); return ( {lines.map((l, i) => ( {l || " "} ))} ); } /** Flatten a (nested) page tree to `{id,title}` for the link dialog picker. */ function flattenPages(entries: { id: string; title: string; children?: unknown[] }[] | undefined): { id: string; title: string }[] { const out: { id: string; title: string }[] = []; const walk = (list: { id: string; title: string; children?: unknown[] }[] | undefined) => { for (const e of list ?? []) { out.push({ id: e.id, title: e.title }); if (Array.isArray(e.children)) walk(e.children as { id: string; title: string; children?: unknown[] }[]); } }; walk(entries); return out; } /** Resolve an element link's URL (internal page link → the app route; url → as-is). */ function linkHref(link: ElementLink | undefined, workspace: string): string | null { if (!link) return null; if (link.kind === "url" && link.href) return link.href; if ((link.kind === "page" || link.kind === "task") && link.refId) return `/w/${workspace}/page/${link.refId}`; return null; } interface WhiteboardCanvasProps { pageId: string; workspaceId: string; /** GUEST (VIEW) or an unvalidatable canvas → read-only. */ readOnly?: boolean; /** The API's validation flag (false → read-only banner). */ canvasValid?: boolean; } export function WhiteboardCanvas({ pageId, workspaceId, readOnly = false, canvasValid = true }: WhiteboardCanvasProps) { const router = useRouter(); const { data, isLoading } = useWhiteboardCanvas(pageId); const save = useWhiteboardSave(pageId); const selfUserId = useAuthStore((s) => s.user?.id ?? null); // ---- the canvas document, owned by the history stack (T-170) ---- const historyRef = useRef | null>(null); if (historyRef.current === null) historyRef.current = new OpStack(emptyCanvas(), HISTORY_CAP); const history = historyRef.current; const [canvas, setCanvas] = useState(() => history.present); const initialized = useRef(false); useEffect(() => { if (data && !initialized.current) { initialized.current = true; // Seed the stack with the server document — `reset` (not push) so undo // stops at the loaded state; the first edit is the first undoable step. historyRef.current!.reset(data.canvas); setCanvas(historyRef.current!.present); } }, [data]); const [viewport, setViewport] = useState({ scale: 1, tx: 32, ty: 32 }); const [selection, setSelection] = useState([]); const [tool, setTool] = useState(readOnly ? "pan" : "select"); const [marquee, setMarquee] = useState<{ a: { x: number; y: number }; b: { x: number; y: number } } | null>(null); const [preview, setPreview] = useState(null); const [editingId, setEditingId] = useState(null); const [dirty, setDirty] = useState(false); /** Connector defaults (T-169: straight vs orthogonal routing + arrowhead). */ const [connectorOrthogonal, setConnectorOrthogonal] = useState(true); const [connectorArrow, setConnectorArrow] = useState(true); // ---- T-170 surfaces ---- const [commentsOpen, setCommentsOpen] = useState(false); const [versionsOpen, setVersionsOpen] = useState(false); const [restoreTarget, setRestoreTarget] = useState<{ id: string; createdAt: string } | null>(null); const [linkTarget, setLinkTarget] = useState(null); // element id for the link dialog const [convertTarget, setConvertTarget] = useState<"page" | "task" | null>(null); const [converting, setConverting] = useState(false); const versionsQuery = usePageVersions(pageId); const svgRef = useRef(null); const dragRef = useRef(null); const dragStartCanvas = useRef(emptyCanvas()); const containerRef = useRef(null); /** The canvas wrap's rendered size (viewport culling — 2,000-object gate). * 0×0 until the first layout (jsdom / before mount) → culling is disabled * (everything renders), so headless tests are unaffected. */ const [size, setSize] = useState({ w: 0, h: 0 }); useEffect(() => { // The canvas wrap only exists after the data loads (the loading state // renders a different subtree, so the ref is null on the FIRST mount). // Depend on `isLoading` so this re-runs when the container mounts and the // observer attaches — that's the moment culling can engage. const el = containerRef.current; // jsdom has no ResizeObserver (and no layout) — skip; size stays 0×0, // which disables culling (everything renders) in headless tests. if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver((entries) => { const r = entries[0]?.contentRect; if (r) setSize({ w: r.width, h: r.height }); }); ro.observe(el); return () => ro.disconnect(); }, [isLoading]); const effectiveReadOnly = readOnly || !canvasValid; /** Apply an immutable op THROUGH the history stack (T-170: every edit is * undoable). The stack is the source of truth for past/future. */ const apply = useCallback((next: Canvas) => { history.push(next); setCanvas(history.present); setDirty(true); }, [history]); /** Undo / redo (⌘Z / ⌘⇧Z). The canvas is swapped wholesale; dirty so the * autosave persists the undone state. */ const undo = useCallback(() => { if (!effectiveReadOnly && history.canUndo()) { const prev = history.undo(); setCanvas(prev); setDirty(true); } }, [history, effectiveReadOnly]); const redo = useCallback(() => { if (!effectiveReadOnly && history.canRedo()) { const next = history.redo(); setCanvas(next); setDirty(true); } }, [history, effectiveReadOnly]); // ---- autosave (debounced; the renderer owns the document, A-004) ---- useEffect(() => { if (!dirty || effectiveReadOnly) return; const t = setTimeout(() => { save.mutate(canvas, { onError: (e) => toast("error", "Whiteboard save failed", (e as Error).message) }); setDirty(false); }, 1200); return () => clearTimeout(t); }, [canvas, dirty, effectiveReadOnly, save]); /** T-170 save & restore: an explicit version snapshot (the server also * snapshots the canvas sidecar). */ const [savingVersion, setSavingVersion] = useState(false); const doSaveVersion = async () => { if (savingVersion) return; setSavingVersion(true); try { // Ensure the current document is saved before snapshotting it. if (dirty) await save.mutateAsync(canvas); await whiteboardApi.saveVersion(pageId); void versionsQuery.refetch(); toast("success", "Version saved"); } catch (e) { toast("error", "Save version failed", e instanceof Error ? e.message : undefined); } finally { setSavingVersion(false); } }; const doRestore = async () => { if (!restoreTarget) return; try { await whiteboardApi.restoreVersion(pageId, restoreTarget.id); // The server wrote the canvas back — re-read it (and the versions list). const wb = await whiteboardApi.get(pageId); // `push` (not reset) so the pre-restore board stays one ⌘Z away // (the confirm dialog promises that). historyRef.current!.push(wb.canvas); setCanvas(historyRef.current!.present); setDirty(false); void versionsQuery.refetch(); toast("success", "Version restored", `Restored to ${restoreTarget.createdAt}`); setRestoreTarget(null); setVersionsOpen(false); } catch (e) { toast("error", "Restore failed", e instanceof Error ? e.message : undefined); } }; /** Screen (client px) → world via the svg's box + viewport. */ const toWorld = useCallback( (clientX: number, clientY: number): { x: number; y: number } => { const box = svgRef.current?.getBoundingClientRect(); const sx = clientX - (box?.left ?? 0); const sy = clientY - (box?.top ?? 0); return screenToWorld(viewport, sx, sy); }, [viewport] ); // ---- zoom (wheel: zoom at cursor; non-passive so preventDefault works) ---- useEffect(() => { const el = containerRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const box = el.getBoundingClientRect(); const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; setViewport((vp) => { const scale = Math.min(8, Math.max(0.05, vp.scale * factor)); const worldX = (e.clientX - box.left - vp.tx) / vp.scale; const worldY = (e.clientY - box.top - vp.ty) / vp.scale; return { scale, tx: e.clientX - box.left - worldX * scale, ty: e.clientY - box.top - worldY * scale }; }); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }, []); /** Group the current selection (needs ≥ 2, no nested groups). */ const doGroup = useCallback(() => { if (effectiveReadOnly || selection.length < 2) return; const gid = newId("g"); try { const next = groupElements(canvas, { id: gid }, selection); apply(next); setSelection([gid]); } catch (err) { toast("error", "Cannot group", (err as Error).message); } }, [canvas, selection, effectiveReadOnly, apply]); /** Ungroup a single selected group (children keep their positions). */ const doUngroup = useCallback(() => { if (effectiveReadOnly || selection.length !== 1) return; const selId = selection[0]; if (selId === undefined) return; const el = canvas.elements[selId]; if (!el || el.type !== "group") return; apply(ungroupElement(canvas, el.id)); setSelection([...el.children]); }, [canvas, selection, effectiveReadOnly, apply]); /** T-170: attach a link to the selected element (or clear it). */ const doSetLink = (link: ElementLink | null) => { if (effectiveReadOnly || selection.length !== 1) return; const id = selection[0]; if (id === undefined) return; apply(updateElement(canvas, id, { link: link ?? null })); setLinkTarget(null); }; /** T-171: convert the selection into a real page (or task page) — the * server re-reads the canvas and maps text elements to blocks. */ const doConvert = async () => { if (converting || effectiveReadOnly || selection.length === 0 || convertTarget === null) return; setConverting(true); try { const res = await whiteboardApi.convertSelection(pageId, { elementIds: selection, target: convertTarget, parentId: null }); toast("success", convertTarget === "task" ? "Task created from selection" : "Page created from selection"); router.push(`/w/${workspaceId}/page/${res.page.id}`); } catch (e) { toast("error", "Convert failed", e instanceof Error ? e.message : undefined); } finally { setConverting(false); } }; /** T-170: open an element's link (internal → app route, url → new tab). */ const openLink = useCallback( (el: CanvasElement) => { const href = linkHref(el.link, workspaceId); if (!href) return; if (el.link!.kind === "url") window.open(href, "_blank", "noopener,noreferrer"); else router.push(href); }, [workspaceId, router] ); // ---- keyboard: undo/redo, delete, tools, group/ungroup, escape, links ---- useEffect(() => { const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null; const tag = t?.tagName; if (tag === "TEXTAREA" || tag === "INPUT" || t?.isContentEditable) return; const mod = e.metaKey || e.ctrlKey; if (mod && e.key.toLowerCase() === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; } if (mod && e.key.toLowerCase() === "y") { e.preventDefault(); redo(); return; } if (mod && e.key.toLowerCase() === "g") { e.preventDefault(); if (e.shiftKey) doUngroup(); else doGroup(); return; } if (mod && e.key.toLowerCase() === "k") { e.preventDefault(); // ⌘K: link the single selected element (open the dialog). const sid = selection[0]; if (!effectiveReadOnly && selection.length === 1 && sid !== undefined) setLinkTarget(sid); return; } if ((e.key === "Delete" || e.key === "Backspace") && !effectiveReadOnly && selection.length > 0) { e.preventDefault(); let c = canvas; for (const id of selection) c = removeElement(c, id); apply(c); setSelection([]); return; } if (e.key === "Enter" && !mod && selection.length === 1) { const eid = selection[0]; const el = eid !== undefined ? canvas.elements[eid] : undefined; if (el?.link) { openLink(el); return; } } if (e.key === "Escape") { setSelection([]); setEditingId(null); setPreview(null); setLinkTarget(null); setVersionsOpen(false); return; } if (effectiveReadOnly) return; if (e.metaKey || e.ctrlKey || e.altKey) return; switch (e.key) { case "v": setTool("select"); break; case "h": setTool("pan"); break; case "s": setTool("sticky"); break; case "t": setTool("text"); break; case "r": setTool("rect"); break; case "o": setTool("ellipse"); break; case "c": setTool("connector"); break; case "p": setTool("pen"); break; } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [canvas, selection, effectiveReadOnly, apply, doGroup, doUngroup, undo, redo, workspaceId]); // ---- pointer gestures ---- const hitAt = (world: { x: number; y: number }): CanvasElement | undefined => Object.values(canvas.elements) .filter((el) => hitTest(el, world.x, world.y, 6, canvas.elements)) .sort((a, b) => b.z - a.z)[0]; const onPointerDown = (e: React.PointerEvent) => { // middle mouse or pan tool → pan if (e.button === 1 || tool === "pan") { e.currentTarget.setPointerCapture(e.pointerId); dragRef.current = { mode: "pan", startWorld: toWorld(e.clientX, e.clientY), startScreen: { x: e.clientX, y: e.clientY }, startView: viewport, moved: false }; return; } if (effectiveReadOnly) return; const world = toWorld(e.clientX, e.clientY); // click-create tools if (tool === "sticky" || tool === "text") { const id = newId("el"); const base = { id, x: world.x - 80, y: world.y - 50, z: nextZ(canvas) }; const el: CanvasElement = tool === "sticky" ? { ...base, type: "sticky", w: 160, h: 110, text: "", color: "yellow" } : { ...base, type: "text", w: 220, h: 32, text: "", fontSize: 16 }; apply(addElement(canvas, el)); setSelection([id]); setEditingId(id); setTool("select"); return; } // drag-create tools (rect/ellipse) if (tool === "rect" || tool === "ellipse") { e.currentTarget.setPointerCapture(e.pointerId); dragStartCanvas.current = canvas; dragRef.current = { mode: "create-shape", shape: tool, startWorld: world, startScreen: { x: e.clientX, y: e.clientY }, startView: viewport, moved: false }; setPreview({ kind: "shape", shape: tool, a: world, b: world }); return; } // connector: must start ON an element (endpoints attach to elements) if (tool === "connector") { const hit = hitAt(world); if (!hit) return; e.currentTarget.setPointerCapture(e.pointerId); const anchor = nearestAnchor(hit, world); dragStartCanvas.current = canvas; dragRef.current = { mode: "connector", fromEl: { elementId: hit.id, anchor }, startWorld: world, startScreen: { x: e.clientX, y: e.clientY }, startView: viewport, moved: false }; setPreview({ kind: "connector", a: world, b: world, fromEl: { elementId: hit.id, anchor } }); return; } // pen: start accumulating world points if (tool === "pen") { e.currentTarget.setPointerCapture(e.pointerId); dragRef.current = { mode: "pen", startWorld: world, points: [world.x, world.y], startScreen: { x: e.clientX, y: e.clientY }, startView: viewport, moved: false }; setPreview({ kind: "pen", a: world, b: world, points: [world.x, world.y] }); return; } // select tool: hit-test topmost (z desc), else marquee const hit = hitAt(world); e.currentTarget.setPointerCapture(e.pointerId); if (hit) { const multi = e.shiftKey && selection.includes(hit.id); const ids = multi ? selection : [hit.id]; setSelection(multi ? selection : ids); // capture the pre-drag document: the gesture re-derives from it on // every move (immutable ops, no float accumulation). dragStartCanvas.current = canvas; dragRef.current = { mode: "move", startWorld: world, ids, startScreen: { x: e.clientX, y: e.clientY }, startView: viewport, moved: false }; } else { if (!e.shiftKey) setSelection([]); dragRef.current = { mode: "marquee", startWorld: world, startScreen: { x: e.clientX, y: e.clientY }, startView: viewport, moved: false }; setMarquee({ a: world, b: world }); } }; const onPointerMove = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; d.moved = true; const world = toWorld(e.clientX, e.clientY); if (d.mode === "pan") { setViewport({ scale: d.startView.scale, tx: d.startView.tx + (e.clientX - d.startScreen.x), ty: d.startView.ty + (e.clientY - d.startScreen.y) }); return; } if (d.mode === "marquee") { setMarquee({ a: d.startWorld, b: world }); return; } if (d.mode === "create-shape") { setPreview((p) => (p && p.kind === "shape" ? { ...p, b: world } : p)); return; } if (d.mode === "connector") { setPreview((p) => (p && p.kind === "connector" ? { ...p, b: world } : p)); return; } if (d.mode === "pen") { const pts = d.points!; const n = pts.length; const dx = world.x - (pts[n - 2] ?? world.x); const dy = world.y - (pts[n - 1] ?? world.y); // sample density scales with zoom (≥ ~1 screen px between samples) if (dx * dx + dy * dy > (1 / viewport.scale) ** 2) { pts.push(world.x, world.y); setPreview((p) => (p && p.kind === "pen" ? { ...p, points: [...pts] } : p)); } return; } if (d.mode === "move" && d.ids && !effectiveReadOnly) { const dx = world.x - d.startWorld.x; const dy = world.y - d.startWorld.y; if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return; // Connectors ride on their endpoint elements — only the non-connectors // move; the connectors re-route themselves (endpoints are id refs). const movable = d.ids.filter((id) => { const el = dragStartCanvas.current.elements[id]; return el && el.type !== "connector"; }); if (movable.length === 0) return; setCanvas(moveMany(dragStartCanvas.current, movable, dx, dy)); setDirty(true); return; } if (d.mode === "resize" && d.ids && !effectiveReadOnly) { const id = d.ids[0]; if (id === undefined) return; const el = dragStartCanvas.current.elements[id]; if (!el) return; const w = Math.max(MIN_SIZE, world.x - el.x); const h = Math.max(MIN_SIZE, world.y - el.y); setCanvas((prev) => updateElement(prev, id, { w, h })); setDirty(true); } }; const onPointerUp = (e: React.PointerEvent) => { const d = dragRef.current; dragRef.current = null; setMarquee(null); setPreview(null); if (!d) return; const world = toWorld(e.clientX, e.clientY); if (d.mode === "marquee" && d.moved) { const x = Math.min(d.startWorld.x, world.x); const y = Math.min(d.startWorld.y, world.y); const x2 = Math.max(d.startWorld.x, world.x); const y2 = Math.max(d.startWorld.y, world.y); // Only the VISIBLE elements are selectable (they're the only ones // painted) — a marquee can't extend past the viewport, so this is // also faster on large boards (no full-document scan). const ids = visibleElements .filter((el) => el.x < x2 && el.x + el.w > x && el.y < y2 && el.y + el.h > y) .map((el) => el.id); setSelection((prev) => (e.shiftKey ? [...new Set([...prev, ...ids])] : ids)); return; } if (d.mode === "create-shape" && d.moved) { const r = normRect(d.startWorld, world); if (r.w >= MIN_SIZE / viewport.scale || r.h >= MIN_SIZE / viewport.scale) { const id = newId("el"); const el: CanvasElement = { id, type: d.shape ?? "rect", x: r.x, y: r.y, w: Math.max(MIN_SIZE, r.w), h: Math.max(MIN_SIZE, r.h), z: nextZ(dragStartCanvas.current), fill: null, stroke: null }; apply(addElement(dragStartCanvas.current, el)); setSelection([id]); } return; } if (d.mode === "connector" && d.moved && d.fromEl) { const target = hitAt(world); if (target && target.id !== d.fromEl.elementId) { const anchor = nearestAnchor(target, world); const id = newId("c"); const el: CanvasElement = { id, type: "connector", x: 0, y: 0, w: 0, h: 0, z: nextZ(dragStartCanvas.current), from: d.fromEl, to: { elementId: target.id, anchor }, kind: connectorOrthogonal ? "orthogonal" : "straight", arrow: connectorArrow }; apply(addElement(dragStartCanvas.current, el)); setSelection([id]); } return; } if (d.mode === "pen" && d.moved && d.points) { // simplify + store (absolute world coords; bounds = the stroke's box) const simplified = decimatePoints(d.points, 1.5); if (simplified.length >= 4) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (let i = 0; i < simplified.length; i += 2) { minX = Math.min(minX, simplified[i]!); maxX = Math.max(maxX, simplified[i]!); minY = Math.min(minY, simplified[i + 1]!); maxY = Math.max(maxY, simplified[i + 1]!); } const id = newId("f"); const el: CanvasElement = { id, type: "freehand", x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY), z: nextZ(dragStartCanvas.current), points: simplified, strokeWidth: 3 }; apply(addElement(dragStartCanvas.current, el)); setSelection([id]); } } }; const onResizeHandleDown = (e: React.PointerEvent) => { e.stopPropagation(); if (effectiveReadOnly || selection.length !== 1) return; const id = selection[0]; if (id === undefined) return; const el = canvas.elements[id]; // freehand/connector/group don't support bounds-resize (v1) — the handle // is only rendered for the resizable types anyway. if (!el || el.type === "connector" || el.type === "freehand" || el.type === "group") return; e.currentTarget.setPointerCapture(e.pointerId); dragStartCanvas.current = canvas; dragRef.current = { mode: "resize", ids: [id], startWorld: toWorld(e.clientX, e.clientY), startScreen: { x: e.clientX, y: e.clientY }, startView: viewport, moved: false }; }; const onDoubleClick = (e: React.MouseEvent) => { if (effectiveReadOnly) return; const world = toWorld(e.clientX, e.clientY); const hit = Object.values(canvas.elements) .filter((el) => (el.type === "sticky" || el.type === "text") && hitTest(el, world.x, world.y, 6, canvas.elements)) .sort((a, b) => b.z - a.z)[0]; if (hit) { setSelection([hit.id]); setEditingId(hit.id); } }; /** T-170: double-click a LINKED element (any type) opens its link — this * runs before the edit handler below (stopPropagation) so a linked sticky * navigates instead of opening the text editor. */ const onElementDoubleClick = (el: CanvasElement) => { if (!el.link || effectiveReadOnly) return; openLink(el); }; // ---- element rendering (display order = z asc; unknown types skipped) ---- // Viewport culling (2,000-object perf gate): the SVG only mounts a DOM node // for elements whose display bounds intersect the visible world rect. A // degenerate size (0×0, jsdom / before layout) disables culling, so the // small interactive boards in the headless tests render in full. const sortedElements = useMemo( () => Object.values(canvas.elements).sort((a, b) => a.z - b.z), [canvas] ); const visibleRect = viewportToWorldRect(viewport, size.w, size.h); const visibleElements = useMemo(() => { if (!visibleRect) return sortedElements; return sortedElements.filter((el) => rectsIntersect(displayBounds(el, canvas.elements), visibleRect)); }, [sortedElements, visibleRect, canvas.elements]); const elements = useMemo( () => visibleElements.map((el) => { const selected = selection.includes(el.id); const key = el.id; const linked = Boolean(el.link); const cursor = linked ? "pointer" : undefined; switch (el.type) { case "sticky": return ( { onElementDoubleClick(el); }} style={linked ? { cursor } : undefined}> ); case "text": return ( { onElementDoubleClick(el); }} style={linked ? { cursor } : undefined}> ); case "rect": return { onElementDoubleClick(el); }} x={el.x} y={el.y} width={el.w} height={el.h} fill={el.fill ?? "transparent"} stroke={el.stroke ?? "#374151"} strokeWidth={1.5} style={linked ? { cursor } : undefined} />; case "ellipse": return { onElementDoubleClick(el); }} cx={el.x + el.w / 2} cy={el.y + el.h / 2} rx={el.w / 2} ry={el.h / 2} fill={el.fill ?? "transparent"} stroke={el.stroke ?? "#374151"} strokeWidth={1.5} style={linked ? { cursor } : undefined} />; case "freehand": return { onElementDoubleClick(el); }} points={pointsAttr(el.points)} fill="none" stroke="#111827" strokeWidth={el.strokeWidth} strokeLinecap="round" strokeLinejoin="round" style={linked ? { cursor } : undefined} />; case "connector": { const from = canvas.elements[el.from.elementId]; const to = canvas.elements[el.to.elementId]; if (!from || !to) return null; const p1 = anchorPoint(from, el.from.anchor); const p2 = anchorPoint(to, el.to.anchor); const dPath = connectorPath(p1, p2, el.kind); const dx = p2.x - p1.x; const dy = p2.y - p1.y; const len = Math.hypot(dx, dy) || 1; const head = arrowHead(p2, { x: dx / len, y: dy / len }, 10); const headStr = head.map((p) => `${p[0]},${p[1]}`).join(" "); return ( {el.arrow && } {/* invisible fat hit area (the thin path is hard to grab) */} ); } case "group": return { onElementDoubleClick(el); }} x={el.x} y={el.y} width={el.w} height={el.h} fill="none" stroke="#9CA3AF" strokeWidth={1.5} strokeDasharray="6 4" rx={4} style={linked ? { cursor } : undefined} />; default: return null; // unknown type (newer schema) → skip, not crash } }), [visibleElements, canvas, openLink] ); // ---- selection overlay (dashed outline + resize handle) ---- const selectedEls = selection .map((id) => canvas.elements[id]) .filter((el): el is CanvasElement => Boolean(el)); const resizableEl = selection.length === 1 && selectedEls[0] && !["connector", "freehand", "group"].includes(selectedEls[0].type) ? selectedEls[0] : null; const connectorFromPoint = preview?.kind === "connector" && preview.fromEl ? (() => { const from = canvas.elements[preview.fromEl!.elementId]; return from ? anchorPoint(from, preview.fromEl!.anchor) : preview.a; })() : null; const editingEl = editingId ? canvas.elements[editingId] : undefined; const commitEdit = useCallback( (text: string) => { if (editingId) { const el = canvas.elements[editingId]; if (el && (el.type === "sticky" || el.type === "text")) apply(updateElement(canvas, editingId, { text })); } setEditingId(null); }, [editingId, canvas, apply] ); // ---- T-170: collaboration indicators (peers on the page channel) ---- const presenceMap = useBlockPatchStore((s) => (pageId ? (s.presence[pageId] ?? ({} as Record)) : ({} as Record))); const peers = useMemo( () => Object.values(presenceMap).filter((p) => p.userId !== selfUserId), [presenceMap, selfUserId] ); const initials = (name: string) => name .split(/\s+/) .map((s) => s[0]) .filter(Boolean) .slice(0, 2) .join("") .toUpperCase(); // ---- link dialog data (page tree) ---- const { data: pageTree } = usePageTree(workspaceId); const pages = useMemo(() => flattenPages(pageTree), [pageTree]); if (isLoading) { return (
Loading whiteboard…
); } const saveState = save.isPending ? "Saving…" : dirty ? "Unsaved" : "Saved"; const canGroup = selection.length >= 2 && !effectiveReadOnly; const firstSel = selection[0]; const canUngroup = selection.length === 1 && firstSel !== undefined && canvas.elements[firstSel]?.type === "group" && !effectiveReadOnly; const canLink = selection.length === 1 && !effectiveReadOnly; const linkEl = linkTarget ? canvas.elements[linkTarget] : undefined; const versionList = (versionsQuery.data ?? []).slice(0, VERSION_LIMIT); const tools: Array<{ key: Tool; testid: string; label: string; title: string }> = [ { key: "select", testid: "wb-tool-select", label: "Select", title: "Select (V)" }, { key: "sticky", testid: "wb-tool-sticky", label: "Sticky", title: "Sticky note (S)" }, { key: "text", testid: "wb-tool-text", label: "Text", title: "Text (T)" }, { key: "rect", testid: "wb-tool-rect", label: "Rect", title: "Rectangle (R)" }, { key: "ellipse", testid: "wb-tool-ellipse", label: "Ellipse", title: "Ellipse (O)" }, { key: "connector", testid: "wb-tool-connector", label: "Connector", title: "Connector (C)" }, { key: "pen", testid: "wb-tool-pen", label: "Pen", title: "Freehand (P)" } ]; return (
{!canvasValid && (
This whiteboard uses a newer/unsupported canvas version — shown read-only.
)} {effectiveReadOnly && canvasValid && (
Read-only (view access).
)} {/* toolbar */}
{!effectiveReadOnly && tools.map((t) => ( ))} {!effectiveReadOnly && ( <> )} {Math.round(viewport.scale * 100)}% {saveState}
{/* T-170: collaboration indicators (peers on the page channel) */}
{peers.slice(0, 5).map((p) => ( {initials(p.name)} ))} {peers.length > 5 && +{peers.length - 5}}
{/* canvas */}
{elements} {/* selection overlay */} {selectedEls.map((el) => ( ))} {resizableEl && ( )} {/* marquee */} {marquee && ( )} {/* in-progress previews */} {preview?.kind === "shape" && normRect(preview.a, preview.b).w > 0 && ( preview.shape === "rect" ? ( ) : ( ) )} {preview?.kind === "connector" && connectorFromPoint && ( )} {preview?.kind === "pen" && preview.points && preview.points.length >= 4 && ( )} {/* inline text edit: a plain