makersclaw 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +2 -2
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +2 -2
- package/r/chat.json +2 -2
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/contact-section.json +2 -2
- package/r/csv-import.json +3 -3
- package/r/data-table.json +2 -2
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +6 -6
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +3 -3
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +3 -3
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +2 -2
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +4 -4
- package/r/registry.json +121 -121
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +2 -2
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +2 -2
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +3 -3
- package/registry/blocks/flow-canvas/README.md +5 -0
- package/registry/blocks/flow-canvas/board.js +22 -0
- package/registry/blocks/flow-canvas/flow-canvas.jsx +8 -2
- package/registry/blocks/flow-canvas/index.js +1 -0
- package/src/cli.mjs +19 -6
- package/src/registry.mjs +38 -4
- package/src/shadcn.mjs +6 -1
package/r/flow-canvas.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "FlowCanvas",
|
|
6
6
|
"description": "A Miro-like board (React Flow) in the workspace theme: sticky notes, shapes, text, frames, cards and images on an infinite dotted canvas; a tool rail with shortcuts, double-click for a sticky, drag-out connectors that re-route as cards move, labels on connectors, a floating toolbar over the selection, right-click menu, undo/redo, copy/paste, snap to grid, minimap, list view and presentation mode. The board is plain JSON; helpers turn it into text or markdown and lay it out. Other blocks add their own node types and rail tools.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.2/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [
|
|
11
11
|
"@xyflow/react@12.11.6"
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
"path": "registry/blocks/flow-canvas/index.js",
|
|
16
16
|
"type": "registry:component",
|
|
17
17
|
"target": "@components/makersclaw/blocks/flow-canvas/index.js",
|
|
18
|
-
"content": "// The FlowCanvas block — `makersclaw add flow-canvas`. Fixed: compose it\n// through its props (README.md beside this file). The board helpers are\n// plain functions and work anywhere, the server included. The canvas\n// blocks built on this one (account-map, process-map, journey-map,\n// idea-board…) import it, never @xyflow/react directly.\nexport { FlowCanvas, useFlowCanvas } from \"./flow-canvas\";\nexport { useCanvas } from \"./context\";\nexport { NodeShell, NodeHandles, EditableText } from \"./nodes\";\nexport { Palette, StickySwatch, ColorDot, ShapeGlyph } from \"./chrome\";\nexport { colorClasses } from \"./theme\";\nexport {\n GRID,\n COLORS,\n STICKY_COLORS,\n COLOR_NAMES,\n SHAPES,\n emptyBoard,\n createNode,\n createEdge,\n cleanBoard,\n readBoard,\n boardToText,\n boardToMarkdown,\n describeNode,\n nodeText,\n autoLayout,\n alignNodes,\n distributeNodes,\n duplicateNodes,\n removeNodes,\n frameContents,\n frameOf,\n nodeRect,\n boardBounds,\n snapPoint,\n snapToCell,\n fitFontSize,\n historyInit,\n historyReducer,\n starterTemplates,\n} from \"./board\";\n"
|
|
18
|
+
"content": "// The FlowCanvas block — `makersclaw add flow-canvas`. Fixed: compose it\n// through its props (README.md beside this file). The board helpers are\n// plain functions and work anywhere, the server included. The canvas\n// blocks built on this one (account-map, process-map, journey-map,\n// idea-board…) import it, never @xyflow/react directly.\nexport { FlowCanvas, useFlowCanvas } from \"./flow-canvas\";\nexport { useCanvas } from \"./context\";\nexport { NodeShell, NodeHandles, EditableText } from \"./nodes\";\nexport { Palette, StickySwatch, ColorDot, ShapeGlyph } from \"./chrome\";\nexport { colorClasses } from \"./theme\";\nexport {\n GRID,\n COLORS,\n STICKY_COLORS,\n COLOR_NAMES,\n SHAPES,\n emptyBoard,\n createNode,\n createEdge,\n cleanBoard,\n readBoard,\n boardToText,\n boardToMarkdown,\n describeNode,\n nodeText,\n autoLayout,\n alignNodes,\n distributeNodes,\n duplicateNodes,\n removeNodes,\n frameContents,\n frameOf,\n nodeRect,\n boardBounds,\n snapPoint,\n snapToCell,\n fitFontSize,\n withFittedSize,\n historyInit,\n historyReducer,\n starterTemplates,\n} from \"./board\";\n"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"path": "registry/blocks/flow-canvas/flow-canvas.jsx",
|
|
22
22
|
"type": "registry:component",
|
|
23
23
|
"target": "@components/makersclaw/blocks/flow-canvas/flow-canvas.jsx",
|
|
24
|
-
"content": "\"use client\";\n\n// FlowCanvas — a Miro-like board on React Flow, in the workspace theme,\n// behind one set of props (README.md beside this file).\n//\n// Client only, one static bundle: the modules load with the page (the\n// app's check refuses import()), and the board itself mounts after the\n// first effect (the mount guard). Until then — on the server and in the\n// first client paint — a box of the same size holds the board as text, so\n// the server HTML already says what is on it.\n//\n// State: nodes and edges live here (React Flow's shape). A change that\n// should be undoable COMMITS: a drag ends, a card is placed, words stop\n// being edited, a colour is picked. A commit runs the block's constraints\n// (`constrain`, `beforeChange`), pushes a snapshot onto the history\n// (board.js historyReducer) and schedules one onChange/onSave after\n// `changeDelay` ms. Selection and the view never commit.\nimport \"@xyflow/react/dist/style.css\";\nimport { useCallback, useEffect, useMemo, useReducer, useRef, useState } from \"react\";\nimport {\n Background,\n BackgroundVariant,\n ConnectionMode,\n MiniMap,\n ReactFlow,\n ReactFlowProvider,\n SelectionMode,\n ViewportPortal,\n applyEdgeChanges,\n applyNodeChanges,\n useConnection,\n useReactFlow,\n useStore,\n} from \"@xyflow/react\";\nimport { ContextMenu, cx } from \"../../ui\";\nimport {\n AUTO_HEIGHT,\n EDITABLE,\n GRID,\n SHAPE_NAMES,\n STICKY_COLORS,\n alignNodes,\n arrangeNodes,\n autoLayout,\n boardBounds,\n boardKey,\n boardToMarkdown,\n boardToText,\n cleanBoard,\n createEdge,\n createNode,\n distributeNodes,\n duplicateNodes,\n emptyBoard,\n facingSides,\n frameContents,\n historyInit,\n historyReducer,\n makeId,\n nodeRect,\n readBoard,\n sidePoint,\n starterTemplates,\n} from \"./board\";\nimport { ConnectMenu, EdgeToolbar, EmptyState, FloatAt, HistoryBar, ListView, PresentBar, SelectionToolbar, ShortcutsSheet, ToolFlyout, ToolRail, ZoomBar, modKey } from \"./chrome\";\nimport { CanvasContext } from \"./context\";\nimport { BUILT_IN_EDGES, connectorGeometry } from \"./edges\";\nimport { BUILT_IN_NODES } from \"./nodes\";\nimport { THEME } from \"./theme\";\n\n/** A ref for the canvas handle: `const board = useFlowCanvas()`, then\n * `<FlowCanvas canvasRef={board} />` and `board.current?.fitView()`. */\nexport function useFlowCanvas() {\n return useRef(null);\n}\n\n// The built-in tools. `place` names the node type a click puts down;\n// `sizable` ones can be dragged out to a size.\nconst TOOLS = {\n select: { id: \"select\", label: \"Select\", icon: \"mouse-pointer\", shortcut: \"V\", kind: \"mode\" },\n hand: { id: \"hand\", label: \"Hand\", icon: \"hand\", shortcut: \"H\", kind: \"mode\" },\n sticky: { id: \"sticky\", label: \"Sticky note\", icon: \"sticky-note\", shortcut: \"N\", kind: \"mode\", place: \"sticky\" },\n shape: { id: \"shape\", label: \"Shape\", icon: \"square\", shortcut: \"S\", kind: \"mode\", place: \"shape\", sizable: true },\n text: { id: \"text\", label: \"Text\", icon: \"type\", shortcut: \"T\", kind: \"mode\", place: \"text\", sizable: true },\n connector: { id: \"connector\", label: \"Connector\", icon: \"connector\", shortcut: \"L\", kind: \"mode\" },\n frame: { id: \"frame\", label: \"Frame\", icon: \"frame\", shortcut: \"F\", kind: \"mode\", place: \"frame\", sizable: true },\n image: { id: \"image\", label: \"Image\", icon: \"image\", shortcut: \"I\", kind: \"action\" },\n};\nconst SHAPE_ICON = { rect: \"square\", ellipse: \"circle\", diamond: \"diamond\", pill: \"minus\" };\nconst DEFAULT_RAIL = [\"select\", \"hand\", \"|\", \"sticky\", \"shape\", \"text\", \"connector\", \"frame\"];\n// Props React Flow compares by identity: made once, not every render.\nconst EDGE_DEFAULTS = { type: \"flow\" };\nconst MULTI_KEYS = [\"Shift\", \"Meta\", \"Control\"];\nconst MIDDLE_BUTTON = [1];\nconst FIT_OPTIONS = { padding: 0.2, maxZoom: 1 };\nconst EDITING_TARGET = '[contenteditable=\"true\"],[contenteditable=\"plaintext-only\"],input,textarea,select,[role=\"menu\"],[role=\"dialog\"],[popover]';\n\n/** Anything board-shaped → React Flow's nodes and edges. */\nfunction prepare(board) {\n const { nodes, edges } = readBoard(board);\n return {\n nodes: nodes.map((n) => ({ ...n, position: { x: Number(n.position?.x) || 0, y: Number(n.position?.y) || 0 }, data: n.data ?? {}, ...(n.type === \"frame\" && n.zIndex === undefined ? { zIndex: -1 } : null) })),\n edges: edges.map((e) => ({ ...e, type: e.type ?? \"flow\", data: e.data ?? {} })),\n };\n}\n\n/**\n * A floating toolbar over something on the board, placed in SCREEN space\n * with left/top and no CSS transform (FloatAt): a kit Menu or Tooltip\n * inside it anchors to its button, and CSS anchor positioning is not told\n * about a transformed ancestor such as React Flow's viewport. Only this\n * re-renders as the view pans and zooms.\n */\nfunction Floating({ at, children }) {\n const [tx, ty, z] = useStore((s) => s.transform);\n return (\n <FloatAt x={at.x * z + tx} top={at.top * z + ty - at.lift} bottom={at.bottom * z + ty + 14}>\n {children}\n </FloatAt>\n );\n}\n\n/** The zoom as a percentage; re-renders alone as the view zooms. */\nfunction ZoomLevel() {\n const z = useStore((s) => s.transform[2]);\n return <>{Math.round((z || 1) * 100)}%</>;\n}\n\nfunction usePrefersReducedMotion() {\n const [reduce, setReduce] = useState(false);\n useEffect(() => {\n const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n const on = () => setReduce(mq.matches);\n on();\n mq.addEventListener(\"change\", on);\n return () => mq.removeEventListener(\"change\", on);\n }, []);\n return reduce;\n}\n\nexport function FlowCanvas(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n if (!mounted) return <Placeholder {...props} />;\n return (\n <ReactFlowProvider>\n <LiveCanvas {...props} />\n </ReactFlowProvider>\n );\n}\n\n/** The server's (and the first paint's) board: its box, and its words for\n * a reader. Not interactive. */\nfunction Placeholder({ value, defaultValue, height = 560, bare = false, className, label = \"Board\", describeNode }) {\n const text = boardToText(value ?? defaultValue ?? emptyBoard(), { describe: describeNode });\n return (\n <div role=\"region\" aria-label={label} aria-busy=\"true\" className={cx(\"relative overflow-hidden bg-background\", !bare && \"rounded-lg border border-border\", className)} style={{ height }}>\n <p className=\"sr-only whitespace-pre-line\">{text}</p>\n </div>\n );\n}\n\nfunction LiveCanvas(props) {\n const {\n value,\n defaultValue,\n readOnly: readOnlyProp = false,\n presentation = false,\n onExitPresentation,\n nodeTypes: ownNodeTypes,\n tools: ownTools,\n rail,\n topBar,\n toolbarExtra,\n contextMenuItems,\n emptyState,\n templates: templatesProp,\n renderCard,\n describeNode,\n palette = STICKY_COLORS,\n snap: snapProp = true,\n grid = GRID,\n minimap: minimapProp = false,\n height = 560,\n bare = false,\n label = \"Board\",\n hideAttribution = false,\n className,\n canvasRef,\n } = props;\n const readOnly = readOnlyProp || presentation;\n const rf = useReactFlow();\n const reduce = usePrefersReducedMotion();\n const duration = reduce ? 0 : 220;\n const propsRef = useRef(props);\n propsRef.current = props;\n\n // ── the board ──────────────────────────────────────────────────────────\n const initial = useRef(null);\n if (!initial.current) initial.current = prepare(value ?? defaultValue ?? emptyBoard());\n const [nodes, setNodes] = useState(initial.current.nodes);\n const [edges, setEdges] = useState(initial.current.edges);\n const nodesRef = useRef(nodes);\n const edgesRef = useRef(edges);\n nodesRef.current = nodes;\n edgesRef.current = edges;\n const [history, dispatch] = useReducer(historyReducer, initial.current, historyInit);\n const historyRef = useRef(history);\n historyRef.current = history;\n const lastKey = useRef(boardKey(initial.current));\n const viewportRef = useRef(readBoard(value ?? defaultValue).viewport);\n\n // ── ui state ───────────────────────────────────────────────────────────\n const [tool, setTool] = useState(\"select\");\n const [stickyColor, setStickyColor] = useState(palette[0] ?? \"warning\");\n const [shapeKind, setShapeKind] = useState(\"rect\");\n const [editing, setEditing] = useState({ node: null, edge: null });\n const [connectMenu, setConnectMenu] = useState(null);\n const [listView, setListView] = useState(false);\n const [minimap, setMinimap] = useState(minimapProp);\n const [snap, setSnap] = useState(snapProp);\n const [sheet, setSheet] = useState(false);\n const [save, setSave] = useState({ state: \"idle\" });\n const [draft, setDraft] = useState(null);\n const [narrow, setNarrow] = useState(false);\n const [announce, setAnnounce] = useState(\"\");\n const [slide, setSlide] = useState(0);\n const [ctxOn, setCtxOn] = useState(\"pane\");\n const wrapRef = useRef(null);\n const frameMembers = useRef(new Map());\n const clip = useRef(null);\n const pasteCount = useRef(0);\n const pointer = useRef(null);\n const suppressClick = useRef(false);\n const ctxPoint = useRef(null);\n const connecting = useConnection((c) => c.inProgress);\n const marquee = useStore((s) => s.userSelectionActive);\n const say = (msg) => setAnnounce((m) => (m === msg ? `${msg} ` : msg));\n\n useEffect(() => setSnap(snapProp), [snapProp]);\n useEffect(() => setMinimap(minimapProp), [minimapProp]);\n\n // The rail moves to the bottom when the CANVAS is narrow (not the window).\n useEffect(() => {\n const el = wrapRef.current;\n if (!el || typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(([e]) => setNarrow(e.contentRect.width < 768));\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // ── committing, history, onChange / onSave ─────────────────────────────\n const emitTimer = useRef(0);\n const dirty = useRef(false);\n const saveSeq = useRef(0);\n const currentBoard = () => cleanBoard({ nodes: nodesRef.current, edges: edgesRef.current, viewport: rf.getViewport?.() ?? viewportRef.current });\n\n const runSave = async (board) => {\n const onSave = propsRef.current.onSave;\n if (!onSave) return;\n const seq = ++saveSeq.current;\n setSave({ state: \"saving\" });\n let res;\n try {\n res = await onSave(board);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (seq !== saveSeq.current) return;\n setSave(res && res.ok === false ? { state: \"error\", error: res.error || \"Not saved\" } : { state: \"saved\" });\n };\n const emitNow = () => {\n clearTimeout(emitTimer.current);\n if (!dirty.current) return;\n dirty.current = false;\n const board = currentBoard();\n lastKey.current = boardKey(board);\n propsRef.current.onChange?.(board);\n runSave(board);\n };\n const scheduleEmit = () => {\n dirty.current = true;\n clearTimeout(emitTimer.current);\n emitTimer.current = setTimeout(emitNow, propsRef.current.changeDelay ?? 400);\n };\n // Leaving with a change not yet sent: send it now.\n useEffect(\n () => () => {\n if (dirty.current) emitNow();\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [],\n );\n\n const [tick, setTick] = useState(0);\n const pending = useRef(null);\n const commit = useCallback((reason = \"edit\", ids) => {\n const prev = pending.current;\n pending.current = { reason, ids: [...(prev?.ids ?? []), ...(ids ?? [])] };\n setTick((t) => t + 1);\n }, []);\n\n useEffect(() => {\n const p = pending.current;\n if (!p) return;\n pending.current = null;\n const { constrain, beforeChange } = propsRef.current;\n let ns = nodesRef.current;\n const touched = new Set(p.ids);\n if (touched.size) {\n ns = ns.map((n) => {\n if (!touched.has(n.id)) return n;\n let next = n;\n // A card whose height follows its words drops the height a resize gave it.\n if (p.reason === \"resize\" && AUTO_HEIGHT.has(n.type) && next.height !== undefined) {\n const { height: _h, ...rest } = next;\n next = rest;\n }\n if (constrain) {\n const out = constrain(next, { reason: p.reason, board: { nodes: ns, edges: edgesRef.current } });\n if (out && typeof out === \"object\") next = { ...next, ...out };\n }\n return next;\n });\n }\n let next = { nodes: ns, edges: edgesRef.current };\n if (beforeChange) {\n const out = beforeChange(cleanBoard(next, { viewport: false }), historyRef.current.present, p.reason);\n if (out === false) {\n const back = prepare(historyRef.current.present);\n const sel = new Set(ns.filter((n) => n.selected).map((n) => n.id));\n setNodes(back.nodes.map((n) => (sel.has(n.id) ? { ...n, selected: true } : n)));\n setEdges(back.edges);\n return;\n }\n if (out && typeof out === \"object\") {\n const sel = new Set(ns.filter((n) => n.selected).map((n) => n.id));\n const b = prepare(out);\n next = { nodes: b.nodes.map((n) => (sel.has(n.id) ? { ...n, selected: true } : n)), edges: b.edges };\n }\n }\n if (next.nodes !== nodesRef.current) setNodes(next.nodes);\n if (next.edges !== edgesRef.current) setEdges(next.edges);\n const before = historyRef.current.key;\n dispatch({ type: \"push\", present: next });\n if (boardKey(next) !== before) scheduleEmit();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [tick]);\n\n const restore = (board) => {\n const b = prepare(board);\n setNodes(b.nodes);\n setEdges(b.edges);\n setEditing({ node: null, edge: null });\n };\n const undo = () => {\n const h = historyRef.current;\n if (!h.past.length) return;\n restore(h.past[h.past.length - 1]);\n dispatch({ type: \"undo\" });\n scheduleEmit();\n say(\"Undone\");\n };\n const redo = () => {\n const h = historyRef.current;\n if (!h.future.length) return;\n restore(h.future[0]);\n dispatch({ type: \"redo\" });\n scheduleEmit();\n say(\"Redone\");\n };\n\n // Controlled: a `value` that is not what we last sent replaces the board\n // (and can be undone).\n useEffect(() => {\n if (value === undefined) return;\n const k = boardKey(value);\n if (k === lastKey.current) return;\n lastKey.current = k;\n const b = prepare(value);\n const sel = new Set(nodesRef.current.filter((n) => n.selected).map((n) => n.id));\n setNodes(b.nodes.map((n) => (sel.has(n.id) ? { ...n, selected: true } : n)));\n setEdges(b.edges);\n dispatch({ type: \"push\", present: b });\n }, [value]);\n\n // ── changing the board ─────────────────────────────────────────────────\n const selectOnly = (ids = [], edgeIds = []) => {\n const want = new Set(ids);\n const wantE = new Set(edgeIds);\n setNodes((ns) => ns.map((n) => (Boolean(n.selected) === want.has(n.id) ? n : { ...n, selected: want.has(n.id) })));\n setEdges((es) => es.map((e) => (Boolean(e.selected) === wantE.has(e.id) ? e : { ...e, selected: wantE.has(e.id) })));\n };\n\n const addNodes = (list, { select = true, edit = false, reason = \"create\", edges: newEdges } = {}) => {\n const arr = (Array.isArray(list) ? list : [list]).filter(Boolean).map((n) => ({\n ...n,\n id: n.id ?? makeId(n.type ?? \"n\"),\n position: n.position ?? { x: 0, y: 0 },\n data: n.data ?? {},\n ...(n.type === \"frame\" && n.zIndex === undefined ? { zIndex: -1 } : null),\n selected: select,\n }));\n if (!arr.length) return [];\n setNodes((ns) => [...(select ? ns.map((n) => (n.selected ? { ...n, selected: false } : n)) : ns), ...arr]);\n if (newEdges?.length) setEdges((es) => [...es.map((e) => (e.selected ? { ...e, selected: false } : e)), ...newEdges.map((e) => ({ ...e, type: e.type ?? \"flow\", data: e.data ?? {} }))]);\n commit(reason, arr.map((n) => n.id));\n if (edit && arr.length === 1) setEditing({ node: arr[0].id, edge: null });\n return arr.map((n) => n.id);\n };\n\n const updateNodes = (ids, fn, { commit: doCommit = true, reason = \"edit\" } = {}) => {\n const set = new Set(ids);\n setNodes((ns) => ns.map((n) => (set.has(n.id) ? fn(n) : n)));\n if (doCommit) commit(reason);\n };\n\n const removeNodes = (ids) => {\n const gone = new Set(ids);\n if (!gone.size) return;\n setNodes((ns) => ns.filter((n) => !gone.has(n.id)));\n setEdges((es) => es.filter((e) => !gone.has(e.source) && !gone.has(e.target)));\n setEditing({ node: null, edge: null });\n commit(\"delete\");\n say(`${gone.size} item${gone.size === 1 ? \"\" : \"s\"} deleted`);\n };\n\n const removeEdges = (ids) => {\n const gone = new Set(ids);\n setEdges((es) => es.filter((e) => !gone.has(e.id)));\n setEditing((ed) => (gone.has(ed.edge) ? { node: null, edge: null } : ed));\n commit(\"delete\");\n };\n\n const deleteSelection = () => {\n const ids = nodesRef.current.filter((n) => n.selected && n.deletable !== false && !n.data?.locked).map((n) => n.id);\n const eids = edgesRef.current.filter((e) => e.selected && e.deletable !== false).map((e) => e.id);\n if (!ids.length && !eids.length) return;\n const gone = new Set(ids);\n const goneE = new Set(eids);\n setNodes((ns) => ns.filter((n) => !gone.has(n.id)));\n setEdges((es) => es.filter((e) => !goneE.has(e.id) && !gone.has(e.source) && !gone.has(e.target)));\n commit(\"delete\");\n say(`${ids.length + eids.length} deleted`);\n };\n\n const duplicate = (ids) => {\n const src = { nodes: nodesRef.current, edges: edgesRef.current };\n const copy = duplicateNodes(src, ids.filter((id) => nodesRef.current.some((n) => n.id === id)), { offset: { x: grid * 2, y: grid * 2 } });\n if (!copy.nodes.length) return;\n addNodes(copy.nodes, { reason: \"paste\", edges: copy.edges });\n say(`${copy.nodes.length} duplicated`);\n };\n\n const pasteBoard = (data, at) => {\n const b = readBoard(data);\n if (!b.nodes.length) return;\n const bounds = boardBounds(b.nodes);\n pasteCount.current += 1;\n const offset = at\n ? { x: Math.round((at.x - bounds.x - bounds.width / 2) / grid) * grid, y: Math.round((at.y - bounds.y - bounds.height / 2) / grid) * grid }\n : { x: grid * 2 * pasteCount.current, y: grid * 2 * pasteCount.current };\n const copy = duplicateNodes(b, b.nodes.map((n) => n.id), { offset });\n addNodes(copy.nodes, { reason: \"paste\", edges: copy.edges });\n say(`${copy.nodes.length} pasted`);\n };\n\n const lock = (ids, on = true) => {\n updateNodes(ids, (n) => {\n const data = { ...n.data };\n if (on) data.locked = true;\n else delete data.locked;\n const next = { ...n, data };\n if (on) {\n next.draggable = false;\n next.deletable = false;\n } else {\n delete next.draggable;\n delete next.deletable;\n }\n return next;\n });\n say(on ? \"Locked\" : \"Unlocked\");\n };\n\n const frameSelection = (ids) => {\n const pick = nodesRef.current.filter((n) => ids.includes(n.id));\n const b = boardBounds(pick);\n if (!b) return;\n const pad = grid * 2;\n const f = createNode(\"frame\", null, { rect: { x: b.x - pad, y: b.y - pad, width: b.width + pad * 2, height: b.height + pad * 2 }, grid, data: { title: \"Frame\" } });\n addNodes(f, { edit: true });\n };\n\n const fitView = (opts) => rf.fitView({ padding: 0.2, maxZoom: 1.25, duration, ...opts });\n const focusNode = (id) => {\n selectOnly([id]);\n const n = nodesRef.current.find((x) => x.id === id);\n if (!n) return;\n const r = nodeRect(n);\n rf.setCenter(r.x + r.width / 2, r.y + r.height / 2, { zoom: Math.max(rf.getZoom(), 0.8), duration });\n requestAnimationFrame(() => wrapRef.current?.querySelector(`.react-flow__node[data-id=\"${CSS.escape(id)}\"]`)?.focus({ preventScroll: true }));\n };\n const centerPoint = () => {\n const r = wrapRef.current?.getBoundingClientRect();\n if (!r) return { x: 0, y: 0 };\n return rf.screenToFlowPosition({ x: r.left + r.width / 2, y: r.top + r.height / 2 });\n };\n\n // The handle. One object for the canvas's life: every method reads the\n // latest state through refs, so a block may keep it.\n const apiRef = useRef(null);\n if (!apiRef.current) apiRef.current = {};\n const api = apiRef.current;\n Object.assign(api, {\n getBoard: currentBoard,\n setBoard: (board, { fit = true } = {}) => {\n restore(board);\n commit(\"load\");\n if (fit) setTimeout(() => fitView(), 60);\n },\n getSelection: () => ({ nodes: nodesRef.current.filter((n) => n.selected), edges: edgesRef.current.filter((e) => e.selected) }),\n addNodes,\n addEdge: (edge) => {\n const e = edge.id ? { type: \"flow\", data: {}, ...edge } : createEdge(edge.source, edge.target, edge);\n setEdges((es) => [...es, e]);\n commit(\"connect\");\n return e.id;\n },\n updateNode: (id, patch, opts) => updateNodes([id], (n) => ({ ...n, ...(typeof patch === \"function\" ? patch(n) : patch) }), opts),\n updateNodeData: (id, patch, opts) => updateNodes([id], (n) => ({ ...n, data: { ...n.data, ...(typeof patch === \"function\" ? patch(n.data) : patch) } }), opts),\n updateNodesData: (ids, patch, opts) => updateNodes(ids, (n) => ({ ...n, data: { ...n.data, ...patch } }), opts),\n removeNodes,\n updateEdge: (id, patch, { commit: doCommit = true } = {}) => {\n setEdges((es) => es.map((e) => (e.id === id ? { ...e, ...(typeof patch === \"function\" ? patch(e) : patch) } : e)));\n if (doCommit) commit(\"edit\");\n },\n removeEdges,\n select: (ids, edgeIds) => selectOnly(ids, edgeIds),\n duplicate,\n lock,\n arrange: (ids, where) => {\n setNodes((ns) => arrangeNodes(ns, ids, where));\n commit(\"arrange\");\n },\n align: (ids, how) => {\n setNodes((ns) => alignNodes(ns, ids, how));\n commit(\"align\", ids);\n },\n distribute: (ids, axis) => {\n setNodes((ns) => distributeNodes(ns, ids, axis));\n commit(\"align\", ids);\n },\n autoLayout: (opts = {}) => {\n setNodes((ns) => autoLayout(ns, edgesRef.current, { grid, ...opts }));\n commit(\"layout\", opts.ids ?? nodesRef.current.map((n) => n.id));\n if (!opts.ids) setTimeout(() => fitView(), 60);\n },\n frameSelection,\n edit: (id) => setEditing({ node: id, edge: null }),\n editEdge: (id) => setEditing({ node: null, edge: id }),\n stopEditing: () => {\n setEditing({ node: null, edge: null });\n // Words emptied on a connector label go away with it.\n setEdges((es) => es.map((e) => (typeof e.label === \"string\" && !e.label.trim() ? { ...e, label: undefined } : e)));\n commit(\"edit\");\n requestAnimationFrame(() => {\n if (!wrapRef.current?.contains(document.activeElement)) wrapRef.current?.focus({ preventScroll: true });\n });\n },\n commit,\n undo,\n redo,\n canUndo: () => historyRef.current.past.length > 0,\n canRedo: () => historyRef.current.future.length > 0,\n setTool: (id) => setTool(id),\n getTool: () => tool,\n fitView,\n focusNode,\n zoomIn: () => rf.zoomIn({ duration }),\n zoomOut: () => rf.zoomOut({ duration }),\n zoomTo: (z) => rf.zoomTo(z, { duration }),\n centerPoint,\n screenToFlow: (p) => rf.screenToFlowPosition(p),\n toText: () => boardToText(currentBoard(), { describe: propsRef.current.describeNode }),\n toMarkdown: (opts) => boardToMarkdown(currentBoard(), { describe: propsRef.current.describeNode, ...opts }),\n save: () => {\n dirty.current = true;\n emitNow();\n },\n readOnly,\n });\n\n useEffect(() => {\n if (!canvasRef) return;\n canvasRef.current = api;\n return () => {\n if (canvasRef.current === api) canvasRef.current = null;\n };\n }, [canvasRef, api]);\n\n // ── tools ──────────────────────────────────────────────────────────────\n const customs = useMemo(\n () =>\n (ownTools ?? []).map((t) => ({ ...t, kind: t.onSelect && !t.create ? \"action\" : \"mode\", custom: true })),\n [ownTools],\n );\n const toolById = useMemo(() => {\n const m = new Map(Object.values(TOOLS).map((t) => [t.id, t]));\n for (const t of customs) m.set(t.id, t);\n return m;\n }, [customs]);\n const railItems = useMemo(() => {\n const ids = rail ?? [...DEFAULT_RAIL, ...customs.map((t) => t.id), ...(props.pickImage ? [\"image\"] : [])];\n return ids.map((id) => (id === \"|\" ? \"|\" : toolById.get(id))).filter(Boolean);\n }, [rail, customs, toolById, props.pickImage]);\n const active = toolById.get(tool) ?? TOOLS.select;\n const placing = Boolean(active.place || active.create);\n const cursorTool = tool === \"hand\" ? \"hand\" : tool === \"connector\" ? \"connector\" : tool === \"text\" ? \"text\" : placing ? \"place\" : \"select\";\n\n const pickTool = (t) => {\n if (!t || readOnly) return;\n setConnectMenu(null);\n if (t.id === \"image\") {\n Promise.resolve(propsRef.current.pickImage?.())\n .then((img) => {\n if (!img?.src) return;\n addNodes(createNode(\"image\", centerPoint(), { grid, data: { src: img.src, alt: img.alt ?? \"\" }, width: img.width, height: img.height }));\n })\n .catch(() => {});\n return;\n }\n if (t.kind === \"action\") {\n t.onSelect?.(api);\n return;\n }\n setTool((cur) => (cur === t.id && t.id !== \"select\" ? \"select\" : t.id));\n if (t.id !== \"select\") selectOnly([]);\n };\n\n /** Put down what a tool makes, at a board point (or filling a rect). */\n const place = (t, point, rect) => {\n let made;\n if (t.create) {\n made = t.create(point, { rect, api, grid });\n } else if (t.place) {\n const data = t.place === \"sticky\" ? { color: stickyColor } : t.place === \"shape\" ? { shape: shapeKind } : undefined;\n made = createNode(t.place, point, { grid: snap ? grid : 0, data, rect: t.place === \"text\" && rect ? { ...rect, height: undefined } : rect });\n if (t.place === \"text\" && rect) delete made.height;\n }\n const list = (Array.isArray(made) ? made : [made]).filter(Boolean);\n if (!list.length) return;\n const one = list.length === 1 ? list[0] : null;\n addNodes(list, { edit: Boolean(one && EDITABLE.has(one.type)) });\n setTool(\"select\");\n say(`${t.label} added`);\n };\n\n // ── keyboard ───────────────────────────────────────────────────────────\n const nudge = useRef(0);\n const selectedNodes = nodes.filter((n) => n.selected);\n const selectedEdges = edges.filter((e) => e.selected);\n\n const frames = useMemo(\n () =>\n nodes\n .filter((n) => n.type === \"frame\")\n .sort((a, b) => {\n const ra = nodeRect(a);\n const rb = nodeRect(b);\n return Math.abs(ra.y - rb.y) > 40 ? ra.y - rb.y : ra.x - rb.x;\n }),\n [nodes],\n );\n const goSlide = (i) => {\n const f = frames[Math.max(0, Math.min(frames.length - 1, i))];\n if (!f) return;\n setSlide(frames.indexOf(f));\n rf.fitView({ nodes: [{ id: f.id }], padding: 0.08, duration: reduce ? 0 : 400 });\n };\n useEffect(() => {\n if (!presentation) return;\n const t = setTimeout(() => (frames.length ? goSlide(0) : fitView()), 80);\n return () => clearTimeout(t);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [presentation]);\n\n const onKeyDownCapture = (e) => {\n const t = e.target;\n if (t?.closest?.(EDITING_TARGET)) return;\n const mod = e.metaKey || e.ctrlKey;\n const k = e.key;\n const low = k.length === 1 ? k.toLowerCase() : k;\n const onControl = Boolean(t?.closest?.(\"button, a[href]\"));\n const done = () => {\n e.preventDefault();\n e.stopPropagation();\n };\n\n // View keys work everywhere, read-only too.\n if (e.shiftKey && !mod && (e.code === \"Digit1\" || k === \"!\")) return done(), fitView();\n if (e.shiftKey && !mod && (e.code === \"Digit2\" || k === \"@\")) {\n done();\n const sel = nodesRef.current.filter((n) => n.selected);\n return sel.length ? fitView({ nodes: sel.map((n) => ({ id: n.id })), maxZoom: 2 }) : fitView();\n }\n if ((mod && (k === \"=\" || k === \"+\")) || (!mod && !onControl && (k === \"+\" || k === \"=\"))) return done(), rf.zoomIn({ duration });\n if ((mod && k === \"-\") || (!mod && !onControl && k === \"-\")) return done(), rf.zoomOut({ duration });\n if (mod && k === \"0\") return done(), rf.zoomTo(1, { duration });\n if (k === \"?\" && !mod) return done(), setSheet(true);\n if (presentation) {\n if (k === \"ArrowRight\" || k === \"PageDown\" || (k === \" \" && !onControl)) return done(), goSlide(slide + 1);\n if (k === \"ArrowLeft\" || k === \"PageUp\") return done(), goSlide(slide - 1);\n if (k === \"Escape\") return done(), propsRef.current.onExitPresentation?.();\n return;\n }\n if (k === \"Escape\") {\n if (editing.node || editing.edge) return done(), setEditing({ node: null, edge: null });\n if (connectMenu) return done(), setConnectMenu(null);\n if (listView) return done(), setListView(false);\n if (tool !== \"select\") return done(), setTool(\"select\");\n if (selectedNodes.length || selectedEdges.length) return done(), selectOnly([]);\n return;\n }\n if (readOnly) return;\n\n if (mod && low === \"z\") return done(), e.shiftKey ? redo() : undo();\n if (mod && low === \"y\") return done(), redo();\n if (mod && low === \"s\") return done(), api.save();\n if (mod && low === \"a\") return done(), selectOnly(nodesRef.current.filter((n) => n.selectable !== false).map((n) => n.id));\n const ids = nodesRef.current.filter((n) => n.selected).map((n) => n.id);\n if (mod && low === \"d\") return done(), ids.length && duplicate(ids);\n if (mod && e.shiftKey && low === \"l\") return done(), ids.length && lock(ids, !nodesRef.current.some((n) => n.selected && n.data?.locked));\n if (mod && k === \"]\") return done(), ids.length && api.arrange(ids, \"front\");\n if (mod && k === \"[\") return done(), ids.length && api.arrange(ids, \"back\");\n if (mod) return;\n\n if (!onControl && (k === \"Delete\" || k === \"Backspace\")) return done(), deleteSelection();\n if (!onControl && k === \"Enter\") {\n const focused = t?.classList?.contains(\"react-flow__node\") ? t.dataset.id : null;\n if (focused && !nodesRef.current.find((n) => n.id === focused)?.selected) return done(), selectOnly([focused]);\n if (ids.length === 1) {\n const n = nodesRef.current.find((x) => x.id === ids[0]);\n if (n && !n.data?.locked) return done(), openNode(n);\n }\n const eid = edgesRef.current.filter((x) => x.selected).map((x) => x.id);\n if (eid.length === 1) return done(), setEditing({ node: null, edge: eid[0] });\n return;\n }\n if (!onControl && k.startsWith(\"Arrow\") && ids.length) {\n done();\n const step = e.shiftKey ? 10 : 1;\n const dx = k === \"ArrowLeft\" ? -step : k === \"ArrowRight\" ? step : 0;\n const dy = k === \"ArrowUp\" ? -step : k === \"ArrowDown\" ? step : 0;\n const movable = nodesRef.current.filter((n) => n.selected && n.draggable !== false && !n.data?.locked).map((n) => n.id);\n updateNodes(movable, (n) => ({ ...n, position: { x: n.position.x + dx, y: n.position.y + dy } }), { commit: false });\n clearTimeout(nudge.current);\n nudge.current = setTimeout(() => commit(\"move\", movable), 400);\n return;\n }\n if (!e.altKey && k.length === 1) {\n const hit = railItems.find((x) => x !== \"|\" && x.shortcut && x.shortcut.toLowerCase() === low);\n if (hit) return done(), pickTool(hit);\n }\n };\n\n // Tab onto a card selects it, so what the keys do is what is shown.\n const onFocusCapture = (e) => {\n const el = e.target;\n if (!el?.classList?.contains(\"react-flow__node\") || !el.matches(\":focus-visible\")) return;\n const id = el.dataset.id;\n const n = nodesRef.current.find((x) => x.id === id);\n if (n && !n.selected && n.selectable !== false) selectOnly([id]);\n };\n\n // Copy, cut, paste: the board's own clipboard, with the text on the\n // system one too (paste a list of lines from elsewhere = one sticky each).\n useEffect(() => {\n const MIME = \"application/x-flow-canvas+json\";\n const activeHere = () => {\n const a = document.activeElement;\n return wrapRef.current?.contains(a) && !a?.closest?.(EDITING_TARGET);\n };\n const copy = (e) => {\n if (!activeHere()) return false;\n const ids = nodesRef.current.filter((n) => n.selected).map((n) => n.id);\n if (!ids.length) return false;\n const all = new Set(ids);\n for (const id of ids) if (nodesRef.current.find((n) => n.id === id)?.type === \"frame\") for (const c of frameContents({ nodes: nodesRef.current }, id)) all.add(c);\n const data = cleanBoard({ nodes: nodesRef.current.filter((n) => all.has(n.id)), edges: edgesRef.current.filter((x) => all.has(x.source) && all.has(x.target)) }, { viewport: false });\n clip.current = data;\n pasteCount.current = 0;\n const text = boardToText(data, { describe: propsRef.current.describeNode });\n clip.current.text = text;\n e.clipboardData?.setData(\"text/plain\", text);\n try {\n e.clipboardData?.setData(MIME, JSON.stringify(data));\n } catch {}\n e.preventDefault();\n return true;\n };\n const onCopy = (e) => copy(e);\n const onCut = (e) => {\n if (propsRef.current.readOnly || propsRef.current.presentation) return;\n if (copy(e)) deleteSelection();\n };\n const onPaste = (e) => {\n if (!activeHere() || propsRef.current.readOnly || propsRef.current.presentation) return;\n let data = null;\n try {\n const raw = e.clipboardData?.getData(MIME);\n if (raw) data = JSON.parse(raw);\n } catch {}\n const text = e.clipboardData?.getData(\"text/plain\") ?? \"\";\n if (!data && clip.current && text === clip.current.text) data = clip.current;\n e.preventDefault();\n if (data) return pasteBoard(data, pointer.current);\n const lines = text\n .split(/\\r?\\n/)\n .map((s) => s.replace(/^\\s*(?:[-*•]|\\d+[.)])\\s+/, \"\").trim())\n .filter(Boolean)\n .slice(0, 24);\n if (!lines.length) return;\n const at = pointer.current ?? centerPoint();\n const cols = Math.min(4, lines.length);\n const made = lines.map((line, i) =>\n createNode(\"sticky\", { x: at.x + (i % cols) * 220 - ((cols - 1) * 220) / 2, y: at.y + Math.floor(i / cols) * 220 }, { grid, data: { text: line, color: stickyColor } }),\n );\n addNodes(made, { reason: \"paste\" });\n };\n document.addEventListener(\"copy\", onCopy);\n document.addEventListener(\"cut\", onCut);\n document.addEventListener(\"paste\", onPaste);\n return () => {\n document.removeEventListener(\"copy\", onCopy);\n document.removeEventListener(\"cut\", onCut);\n document.removeEventListener(\"paste\", onPaste);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [stickyColor]);\n\n // ── pointer: focus, double-click, drag-to-size ─────────────────────────\n const isPane = (el) => el?.classList?.contains(\"react-flow__pane\");\n const onPointerDownCapture = (e) => {\n const a = document.activeElement;\n if (!e.target.closest?.(EDITING_TARGET) && !wrapRef.current?.contains(a)) wrapRef.current?.focus({ preventScroll: true });\n if (!isPane(e.target) || e.button !== 0 || readOnly) return;\n if (connectMenu) setConnectMenu(null);\n if (!active.sizable) return;\n const start = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });\n const origin = { x: e.clientX, y: e.clientY };\n let moved = false;\n const rectFrom = (ev) => {\n const p = rf.screenToFlowPosition({ x: ev.clientX, y: ev.clientY });\n const s = snap ? grid : 1;\n const x1 = Math.round(Math.min(start.x, p.x) / s) * s;\n const y1 = Math.round(Math.min(start.y, p.y) / s) * s;\n const x2 = Math.round(Math.max(start.x, p.x) / s) * s;\n const y2 = Math.round(Math.max(start.y, p.y) / s) * s;\n return { x: x1, y: y1, width: Math.max(s, x2 - x1), height: Math.max(s, y2 - y1) };\n };\n const move = (ev) => {\n if (!moved && Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) < 5) return;\n moved = true;\n setDraft(rectFrom(ev));\n };\n const up = (ev) => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n setDraft(null);\n if (!moved) return;\n suppressClick.current = true;\n const r = rectFrom(ev);\n if (r.width < grid * 2 && r.height < grid * 2) return;\n place(active, { x: r.x + r.width / 2, y: r.y + r.height / 2 }, r);\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n };\n\n const onPaneClick = (e) => {\n setConnectMenu(null);\n if (editing.node || editing.edge) setEditing({ node: null, edge: null });\n if (suppressClick.current) {\n suppressClick.current = false;\n return;\n }\n if (readOnly || !placing) return;\n place(active, rf.screenToFlowPosition({ x: e.clientX, y: e.clientY }));\n };\n\n const onDoubleClick = (e) => {\n if (readOnly || tool !== \"select\" || !isPane(e.target)) return;\n const p = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });\n const n = createNode(\"sticky\", p, { grid: snap ? grid : 0, data: { color: stickyColor } });\n addNodes(n, { edit: true });\n say(\"Sticky added\");\n };\n\n const openNode = (n) => {\n const { onOpenNode } = propsRef.current;\n const custom = !BUILT_IN_NODES[n.type];\n if (onOpenNode && (custom || (n.type === \"card\" && renderCard) || readOnly)) return onOpenNode(n);\n if (readOnly || n.data?.locked) return;\n if (EDITABLE.has(n.type) || custom) setEditing({ node: n.id, edge: null });\n };\n\n // ── React Flow wiring ──────────────────────────────────────────────────\n const onNodesChange = useCallback((changes) => {\n const real = changes.filter((c) => c.type !== \"remove\");\n setNodes((nds) => {\n const members = frameMembers.current;\n if (!members.size) return applyNodeChanges(real, nds);\n const byId = new Map(nds.map((n) => [n.id, n]));\n const moving = new Set(real.filter((c) => c.type === \"position\").map((c) => c.id));\n const extra = [];\n for (const c of real) {\n if (c.type !== \"position\" || !c.position || !members.has(c.id)) continue;\n const prev = byId.get(c.id);\n if (!prev) continue;\n const dx = c.position.x - prev.position.x;\n const dy = c.position.y - prev.position.y;\n if (!dx && !dy) continue;\n for (const m of members.get(c.id)) {\n const mn = byId.get(m);\n if (!mn || moving.has(m)) continue;\n moving.add(m);\n extra.push({ type: \"position\", id: m, position: { x: mn.position.x + dx, y: mn.position.y + dy } });\n }\n }\n return applyNodeChanges(extra.length ? [...real, ...extra] : real, nds);\n });\n }, []);\n const onEdgesChange = useCallback((changes) => setEdges((es) => applyEdgeChanges(changes.filter((c) => c.type !== \"remove\"), es)), []);\n\n const dragStart = (dragged) => {\n setConnectMenu(null);\n const ids = new Set((dragged ?? []).map((n) => n.id));\n const m = new Map();\n for (const n of dragged ?? []) {\n if (n.type !== \"frame\") continue;\n const inside = frameContents({ nodes: nodesRef.current }, n.id).filter((id) => !ids.has(id));\n if (inside.length) m.set(n.id, inside);\n }\n frameMembers.current = m;\n };\n const dragStop = (dragged) => {\n const ids = (dragged ?? []).map((n) => n.id);\n for (const list of frameMembers.current.values()) ids.push(...list);\n frameMembers.current = new Map();\n commit(\"move\", ids);\n };\n\n const isValidConnection = (c) => {\n if (c.source === c.target) return false;\n const can = propsRef.current.canConnect;\n return can ? can(c, { nodes: nodesRef.current, edges: edgesRef.current }) !== false : true;\n };\n const onConnect = (c) => {\n if (!isValidConnection(c)) return;\n if (edgesRef.current.some((e) => e.source === c.source && e.target === c.target)) return;\n const e = createEdge(c.source, c.target);\n setEdges((es) => [...es, e]);\n commit(\"connect\");\n say(\"Connected\");\n };\n const onConnectEnd = (event, state) => {\n if (readOnly || state?.isValid || state?.toNode || !state?.fromNode) return;\n const pt = \"changedTouches\" in event && event.changedTouches?.length ? event.changedTouches[0] : event;\n const box = wrapRef.current?.getBoundingClientRect();\n if (!box) return;\n const flow = rf.screenToFlowPosition({ x: pt.clientX, y: pt.clientY });\n setConnectMenu({ at: { x: Math.min(pt.clientX - box.left, box.width - 200), y: Math.min(pt.clientY - box.top, box.height - 240) }, flow, from: state.fromNode.id });\n };\n\n const connectItems = useMemo(() => {\n if (!connectMenu) return [];\n const { flow, from } = connectMenu;\n const make = (node) => {\n if (!node) return;\n const list = Array.isArray(node) ? node : [node];\n const first = list[0];\n addNodes(list, {\n edges: first ? [createEdge(from, first.id)] : [],\n edit: list.length === 1 && EDITABLE.has(first?.type),\n });\n setConnectMenu(null);\n };\n const built = [\n { label: \"Sticky note\", icon: \"sticky-note\", onSelect: () => make(createNode(\"sticky\", flow, { grid, data: { color: stickyColor } })) },\n ...Object.entries(SHAPE_ICON).map(([k, icon]) => ({ label: SHAPE_NAMES[k], icon, onSelect: () => make(createNode(\"shape\", flow, { grid, data: { shape: k } })) })),\n { label: \"Text\", icon: \"type\", onSelect: () => make(createNode(\"text\", flow, { grid })) },\n ];\n const own = customs.filter((t) => t.create && t.connectable !== false).map((t) => ({ label: t.label, icon: t.icon, onSelect: () => make(t.create(flow, { api, grid })) }));\n return [...built, own.length ? { type: \"separator\" } : null, ...own];\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [connectMenu, customs, stickyColor]);\n\n // ── the context menu (right-click, long press, Shift+F10) ───────────────\n const ctxItems = (() => {\n const m = modKey();\n if (readOnly) {\n return [\n { label: \"Fit to screen\", icon: \"maximize\", shortcut: \"⇧1\", onSelect: () => fitView() },\n { label: \"List view\", icon: \"list\", onSelect: () => setListView(true) },\n ];\n }\n const sel = selectedNodes;\n if (ctxOn === \"edge\" && selectedEdges.length) {\n const ids = selectedEdges.map((x) => x.id);\n return [\n ids.length === 1 && { label: selectedEdges[0].label ? \"Edit label\" : \"Add label\", icon: \"pencil\", shortcut: \"↵\", onSelect: () => api.editEdge(ids[0]) },\n { label: \"Delete\", icon: \"trash\", tone: \"danger\", shortcut: \"⌫\", onSelect: () => removeEdges(ids) },\n ];\n }\n if (ctxOn === \"selection\" && sel.length) {\n const ids = sel.map((n) => n.id);\n const anyLocked = sel.some((n) => n.data?.locked);\n const single = sel.length === 1 ? sel[0] : null;\n const extra = typeof contextMenuItems === \"function\" ? contextMenuItems({ nodes: sel, edges: selectedEdges }, api) : null;\n return [\n single && !anyLocked && { label: \"Edit\", icon: \"pencil\", shortcut: \"↵\", onSelect: () => openNode(single) },\n { label: \"Duplicate\", icon: \"copy\", shortcut: `${m}D`, onSelect: () => duplicate(ids) },\n !sel.every((n) => n.type === \"frame\") && { label: \"Frame selection\", icon: \"frame\", onSelect: () => frameSelection(ids) },\n { type: \"separator\" },\n { label: \"Bring to front\", icon: \"bring-front\", shortcut: `${m}]`, onSelect: () => api.arrange(ids, \"front\") },\n { label: \"Send to back\", icon: \"send-back\", shortcut: `${m}[`, onSelect: () => api.arrange(ids, \"back\") },\n sel.length > 1 && {\n label: \"Align\",\n icon: \"align-center\",\n items: [\n { label: \"Left\", onSelect: () => api.align(ids, \"left\") },\n { label: \"Centre\", onSelect: () => api.align(ids, \"center\") },\n { label: \"Right\", onSelect: () => api.align(ids, \"right\") },\n { type: \"separator\" },\n { label: \"Top\", onSelect: () => api.align(ids, \"top\") },\n { label: \"Middle\", onSelect: () => api.align(ids, \"middle\") },\n { label: \"Bottom\", onSelect: () => api.align(ids, \"bottom\") },\n sel.length > 2 && { type: \"separator\" },\n sel.length > 2 && { label: \"Distribute horizontally\", onSelect: () => api.distribute(ids, \"horizontal\") },\n sel.length > 2 && { label: \"Distribute vertically\", onSelect: () => api.distribute(ids, \"vertical\") },\n { type: \"separator\" },\n { label: \"Tidy up\", onSelect: () => api.autoLayout({ ids }) },\n ],\n },\n { label: anyLocked ? \"Unlock\" : \"Lock\", icon: anyLocked ? \"lock-open\" : \"lock\", shortcut: `⇧${m}L`, onSelect: () => lock(ids, !anyLocked) },\n ...(extra?.length ? [{ type: \"separator\" }, ...extra] : []),\n { type: \"separator\" },\n { label: \"Delete\", icon: \"trash\", tone: \"danger\", shortcut: \"⌫\", disabled: sel.every((n) => n.data?.locked || n.deletable === false), onSelect: deleteSelection },\n ];\n }\n const at = () => ctxPoint.current ?? centerPoint();\n const extra = typeof contextMenuItems === \"function\" ? contextMenuItems({ nodes: [], edges: [] }, api) : null;\n return [\n { label: \"Add sticky here\", icon: \"sticky-note\", onSelect: () => addNodes(createNode(\"sticky\", at(), { grid, data: { color: stickyColor } }), { edit: true }) },\n { label: \"Add shape here\", icon: \"square\", onSelect: () => addNodes(createNode(\"shape\", at(), { grid, data: { shape: shapeKind } }), { edit: true }) },\n { label: \"Add text here\", icon: \"type\", onSelect: () => addNodes(createNode(\"text\", at(), { grid }), { edit: true }) },\n { label: \"Add frame here\", icon: \"frame\", onSelect: () => addNodes(createNode(\"frame\", at(), { grid }), { edit: true }) },\n { type: \"separator\" },\n clip.current && { label: \"Paste here\", icon: \"copy\", shortcut: `${m}V`, onSelect: () => pasteBoard(clip.current, at()) },\n nodes.length > 0 && { label: \"Select all\", shortcut: `${m}A`, onSelect: () => selectOnly(nodesRef.current.map((n) => n.id)) },\n nodes.length > 1 && { label: \"Tidy up everything\", icon: \"sparkles\", onSelect: () => api.autoLayout() },\n ...(extra?.length ? [{ type: \"separator\" }, ...extra] : []),\n { type: \"separator\" },\n { label: \"Fit to screen\", icon: \"maximize\", shortcut: \"⇧1\", onSelect: () => fitView() },\n { type: \"checkbox\", label: \"Snap to grid\", checked: snap, onCheckedChange: setSnap },\n { label: \"Keyboard shortcuts\", icon: \"keyboard\", shortcut: \"?\", onSelect: () => setSheet(true) },\n ];\n })();\n\n // ── node and edge types, context ───────────────────────────────────────\n const nodeTypes = useMemo(() => ({ ...BUILT_IN_NODES, ...(ownNodeTypes ?? {}) }), [ownNodeTypes]);\n const soleEdge = selectedEdges.length === 1 && !selectedNodes.length ? selectedEdges[0].id : null;\n const ctx = useMemo(\n () => ({ api, tool, editingNode: editing.node, editingEdge: editing.edge, soleEdge, readOnly, palette, renderCard, connecting, grid }),\n [api, tool, editing.node, editing.edge, soleEdge, readOnly, palette, renderCard, connecting, grid],\n );\n\n // React Flow's event props, one stable function each: a new function per\n // render would redraw every node on every render (a pan, a keystroke).\n const live = useRef({});\n live.current = {\n onConnect,\n onConnectEnd,\n isValidConnection,\n onNodeDragStart: (_, __, dragged) => dragStart(dragged),\n onNodeDragStop: (_, __, dragged) => dragStop(dragged),\n onSelectionDragStart: (_, dragged) => dragStart(dragged),\n onSelectionDragStop: (_, dragged) => dragStop(dragged),\n onNodeDoubleClick: (e, n) => {\n e.stopPropagation();\n openNode(n);\n },\n onEdgeDoubleClick: (e, ed) => {\n e.stopPropagation();\n if (!readOnly) setEditing({ node: null, edge: ed.id });\n },\n onPaneClick,\n onPaneContextMenu: (e) => {\n ctxPoint.current = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });\n setCtxOn(\"pane\");\n },\n onNodeContextMenu: (_, n) => {\n if (!n.selected) selectOnly([n.id]);\n setCtxOn(\"selection\");\n },\n onSelectionContextMenu: () => setCtxOn(\"selection\"),\n onEdgeContextMenu: (_, ed) => {\n if (!ed.selected) selectOnly([], [ed.id]);\n setCtxOn(\"edge\");\n },\n onSelectionChange: ({ nodes: sn, edges: se }) => propsRef.current.onSelectionChange?.(sn, se),\n onMoveEnd: (_, vp) => {\n viewportRef.current = vp;\n },\n };\n const handlers = useMemo(() => Object.fromEntries(Object.keys(live.current).map((k) => [k, (...a) => live.current[k](...a)])), []);\n\n const dragging = nodes.some((n) => n.dragging) || marquee;\n const snapGrid = useMemo(() => [grid, grid], [grid]);\n const proOptions = useMemo(() => ({ hideAttribution }), [hideAttribution]);\n const showToolbar = !readOnly && tool === \"select\" && selectedNodes.length > 0 && !dragging && !editing.node && !connectMenu && !listView;\n const templates = templatesProp ?? starterTemplates();\n\n // Where the floating toolbars hang, in board coordinates (Floating turns\n // them into screen ones as the view moves).\n const selBounds = showToolbar ? boardBounds(selectedNodes) : null;\n const selBox = selBounds\n ? {\n x: selBounds.x + selBounds.width / 2,\n top: selBounds.y,\n bottom: selBounds.y + selBounds.height,\n // A frame's title sits above its box: clear it.\n lift: selectedNodes.some((n) => n.type === \"frame\") ? 34 : 14,\n }\n : null;\n const soleEdgeObj = !readOnly && tool === \"select\" && soleEdge && !editing.edge && !connectMenu && !listView ? selectedEdges[0] : null;\n const edgeBar = (() => {\n if (!soleEdgeObj) return null;\n const s = nodes.find((n) => n.id === soleEdgeObj.source);\n const t = nodes.find((n) => n.id === soleEdgeObj.target);\n if (!s || !t) return null;\n const ra = nodeRect(s);\n const rb = nodeRect(t);\n const sides = facingSides(ra, rb);\n const g = connectorGeometry(sidePoint(ra, sides.source), sides.source, sidePoint(rb, sides.target), sides.target, soleEdgeObj.data?.line ?? \"curved\");\n return { at: { x: g.mid.x, top: g.mid.y, bottom: g.mid.y, lift: 26 }, edge: soleEdgeObj };\n })();\n const isEmpty = nodes.length === 0;\n const board = listView ? cleanBoard({ nodes, edges }, { viewport: false }) : null;\n\n return (\n <CanvasContext.Provider value={ctx}>\n <div\n ref={wrapRef}\n role=\"region\"\n aria-label={label}\n aria-roledescription=\"board\"\n tabIndex={-1}\n data-tool={cursorTool}\n data-connecting={connecting ? \"true\" : \"false\"}\n className={cx(\"relative isolate overflow-hidden bg-background text-foreground outline-none\", !bare && \"rounded-lg border border-border\", THEME, className)}\n style={{ height }}\n onKeyDownCapture={onKeyDownCapture}\n onFocusCapture={onFocusCapture}\n onPointerDownCapture={onPointerDownCapture}\n onPointerMove={(e) => {\n if (e.pointerType === \"mouse\") pointer.current = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });\n }}\n onPointerLeave={() => {\n pointer.current = null;\n }}\n onDoubleClick={onDoubleClick}\n >\n <p className=\"sr-only\" aria-live=\"polite\">\n {announce}\n </p>\n <ContextMenu items={ctxItems} label={`${label} actions`} className=\"absolute inset-0\">\n <ReactFlow\n nodes={nodes}\n edges={edges}\n nodeTypes={nodeTypes}\n edgeTypes={BUILT_IN_EDGES}\n defaultEdgeOptions={EDGE_DEFAULTS}\n onNodesChange={onNodesChange}\n onEdgesChange={onEdgesChange}\n {...handlers}\n connectionMode={ConnectionMode.Loose}\n connectionRadius={28}\n snapToGrid={snap}\n snapGrid={snapGrid}\n panOnScroll\n zoomOnScroll={false}\n zoomOnPinch\n zoomOnDoubleClick={false}\n panOnDrag={tool === \"hand\" ? true : MIDDLE_BUTTON}\n selectionOnDrag={tool === \"select\" && !readOnly}\n selectionMode={SelectionMode.Full}\n multiSelectionKeyCode={MULTI_KEYS}\n deleteKeyCode={null}\n nodesDraggable={!readOnly && (tool === \"select\" || placing)}\n nodesConnectable={!readOnly}\n elementsSelectable={!readOnly && tool !== \"hand\"}\n elevateNodesOnSelect={false}\n elevateEdgesOnSelect\n minZoom={0.1}\n maxZoom={4}\n fitView={!viewportRef.current}\n fitViewOptions={FIT_OPTIONS}\n defaultViewport={viewportRef.current ?? undefined}\n proOptions={proOptions}\n attributionPosition={narrow ? \"top-left\" : \"bottom-center\"}\n colorMode=\"light\"\n aria-label={label}\n >\n <Background variant={BackgroundVariant.Dots} gap={grid} size={1.6} />\n {minimap && !narrow ? <MiniMap pannable zoomable ariaLabel=\"Minimap\" position=\"bottom-right\" nodeBorderRadius={6} className=\"bottom-14!\" /> : null}\n {draft ? (\n <ViewportPortal>\n <div\n aria-hidden=\"true\"\n className=\"absolute top-0 left-0 rounded-md border-2 border-dashed border-primary bg-primary/5\"\n style={{ transform: `translate(${draft.x}px, ${draft.y}px)`, width: draft.width, height: draft.height }}\n />\n </ViewportPortal>\n ) : null}\n </ReactFlow>\n </ContextMenu>\n\n {isEmpty && !readOnly && emptyState !== false && !listView && tool === \"select\" ? (\n emptyState ? (\n <div className=\"pointer-events-none absolute inset-0 grid place-items-center p-6\">\n <div className=\"fc-chrome pointer-events-auto\">{emptyState}</div>\n </div>\n ) : (\n <EmptyState\n templates={templates}\n narrow={narrow}\n onTemplate={(t) => {\n const b = typeof t.board === \"function\" ? t.board() : t.board;\n api.setBoard(b);\n say(`${t.label} template added`);\n }}\n />\n )\n ) : null}\n\n {showToolbar && selBox ? (\n <Floating at={selBox}>\n <SelectionToolbar nodes={selectedNodes} api={api} palette={palette} extra={toolbarExtra} />\n </Floating>\n ) : null}\n {edgeBar ? (\n <Floating at={edgeBar.at}>\n <EdgeToolbar id={edgeBar.edge.id} data={edgeBar.edge.data} label={edgeBar.edge.label} />\n </Floating>\n ) : null}\n\n {topBar ? <div className=\"pointer-events-none absolute inset-x-3 top-3 z-10 flex justify-center [&>*]:pointer-events-auto\">{topBar}</div> : null}\n\n {!readOnly ? (\n <div className={cx(\"pointer-events-none absolute z-10 flex gap-2\", narrow ? \"inset-x-3 bottom-3 flex-col-reverse items-center\" : \"inset-y-3 left-3 flex-row items-center\")}>\n <ToolRail items={railItems} tool={tool} onPick={pickTool} narrow={narrow} />\n <ToolFlyout tool={tool} palette={palette} stickyColor={stickyColor} onStickyColor={setStickyColor} shapeKind={shapeKind} onShapeKind={setShapeKind} />\n </div>\n ) : null}\n\n {!readOnly ? (\n <div className={cx(\"absolute left-3 z-10\", narrow ? (tool === \"sticky\" || tool === \"shape\" ? \"bottom-32\" : \"bottom-20\") : \"bottom-3\")}>\n <HistoryBar canUndo={history.past.length > 0} canRedo={history.future.length > 0} onUndo={undo} onRedo={redo} save={save} onRetry={() => runSave(currentBoard())} />\n </div>\n ) : null}\n\n <div className={cx(\"absolute right-3 z-10\", narrow ? (topBar ? \"top-16\" : \"top-3\") : \"bottom-3\")}>\n <ZoomBar\n zoom={<ZoomLevel />}\n narrow={narrow}\n presentation={presentation}\n onZoomIn={() => rf.zoomIn({ duration })}\n onZoomOut={() => rf.zoomOut({ duration })}\n onZoomTo={(z) => rf.zoomTo(z, { duration })}\n onFit={() => fitView()}\n onFitSelection={() => fitView({ nodes: selectedNodes.map((n) => ({ id: n.id })), maxZoom: 2 })}\n hasSelection={selectedNodes.length > 0}\n minimap={minimap}\n onMinimap={setMinimap}\n list={listView}\n onList={setListView}\n snap={snap}\n onSnap={setSnap}\n onShortcuts={() => setSheet(true)}\n />\n </div>\n\n {presentation ? (\n <div className=\"pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center\">\n <PresentBar\n index={slide}\n count={frames.length}\n title={frames[slide]?.data?.title || \"Untitled frame\"}\n onPrev={() => goSlide(slide - 1)}\n onNext={() => goSlide(slide + 1)}\n onExit={onExitPresentation}\n />\n </div>\n ) : null}\n\n {connectMenu ? <ConnectMenu at={connectMenu.at} items={connectItems} onClose={() => setConnectMenu(null)} /> : null}\n\n {listView ? (\n <ListView\n board={board}\n label={label}\n describe={describeNode}\n iconOf={(n) => toolById.get(n.type)?.icon}\n onClose={() => setListView(false)}\n onPick={(id) => {\n setListView(false);\n requestAnimationFrame(() => focusNode(id));\n }}\n />\n ) : null}\n\n <ShortcutsSheet open={sheet} onClose={() => setSheet(false)} tools={railItems} />\n </div>\n </CanvasContext.Provider>\n );\n}\n\n"
|
|
24
|
+
"content": "\"use client\";\n\n// FlowCanvas — a Miro-like board on React Flow, in the workspace theme,\n// behind one set of props (README.md beside this file).\n//\n// Client only, one static bundle: the modules load with the page (the\n// app's check refuses import()), and the board itself mounts after the\n// first effect (the mount guard). Until then — on the server and in the\n// first client paint — a box of the same size holds the board as text, so\n// the server HTML already says what is on it.\n//\n// State: nodes and edges live here (React Flow's shape). A change that\n// should be undoable COMMITS: a drag ends, a card is placed, words stop\n// being edited, a colour is picked. A commit runs the block's constraints\n// (`constrain`, `beforeChange`), pushes a snapshot onto the history\n// (board.js historyReducer) and schedules one onChange/onSave after\n// `changeDelay` ms. Selection and the view never commit.\nimport \"@xyflow/react/dist/style.css\";\nimport { useCallback, useEffect, useMemo, useReducer, useRef, useState } from \"react\";\nimport {\n Background,\n BackgroundVariant,\n ConnectionMode,\n MiniMap,\n ReactFlow,\n ReactFlowProvider,\n SelectionMode,\n ViewportPortal,\n applyEdgeChanges,\n applyNodeChanges,\n useConnection,\n useReactFlow,\n useStore,\n} from \"@xyflow/react\";\nimport { ContextMenu, cx } from \"../../ui\";\nimport {\n AUTO_HEIGHT,\n EDITABLE,\n GRID,\n SHAPE_NAMES,\n STICKY_COLORS,\n alignNodes,\n arrangeNodes,\n autoLayout,\n boardBounds,\n boardKey,\n boardToMarkdown,\n boardToText,\n cleanBoard,\n createEdge,\n createNode,\n distributeNodes,\n duplicateNodes,\n emptyBoard,\n facingSides,\n frameContents,\n historyInit,\n historyReducer,\n makeId,\n nodeRect,\n readBoard,\n sidePoint,\n starterTemplates,\n withFittedSize,\n} from \"./board\";\nimport { ConnectMenu, EdgeToolbar, EmptyState, FloatAt, HistoryBar, ListView, PresentBar, SelectionToolbar, ShortcutsSheet, ToolFlyout, ToolRail, ZoomBar, modKey } from \"./chrome\";\nimport { CanvasContext } from \"./context\";\nimport { BUILT_IN_EDGES, connectorGeometry } from \"./edges\";\nimport { BUILT_IN_NODES } from \"./nodes\";\nimport { THEME } from \"./theme\";\n\n/** A ref for the canvas handle: `const board = useFlowCanvas()`, then\n * `<FlowCanvas canvasRef={board} />` and `board.current?.fitView()`. */\nexport function useFlowCanvas() {\n return useRef(null);\n}\n\n// The built-in tools. `place` names the node type a click puts down;\n// `sizable` ones can be dragged out to a size.\nconst TOOLS = {\n select: { id: \"select\", label: \"Select\", icon: \"mouse-pointer\", shortcut: \"V\", kind: \"mode\" },\n hand: { id: \"hand\", label: \"Hand\", icon: \"hand\", shortcut: \"H\", kind: \"mode\" },\n sticky: { id: \"sticky\", label: \"Sticky note\", icon: \"sticky-note\", shortcut: \"N\", kind: \"mode\", place: \"sticky\" },\n shape: { id: \"shape\", label: \"Shape\", icon: \"square\", shortcut: \"S\", kind: \"mode\", place: \"shape\", sizable: true },\n text: { id: \"text\", label: \"Text\", icon: \"type\", shortcut: \"T\", kind: \"mode\", place: \"text\", sizable: true },\n connector: { id: \"connector\", label: \"Connector\", icon: \"connector\", shortcut: \"L\", kind: \"mode\" },\n frame: { id: \"frame\", label: \"Frame\", icon: \"frame\", shortcut: \"F\", kind: \"mode\", place: \"frame\", sizable: true },\n image: { id: \"image\", label: \"Image\", icon: \"image\", shortcut: \"I\", kind: \"action\" },\n};\nconst SHAPE_ICON = { rect: \"square\", ellipse: \"circle\", diamond: \"diamond\", pill: \"minus\" };\nconst DEFAULT_RAIL = [\"select\", \"hand\", \"|\", \"sticky\", \"shape\", \"text\", \"connector\", \"frame\"];\n// Props React Flow compares by identity: made once, not every render.\nconst EDGE_DEFAULTS = { type: \"flow\" };\nconst MULTI_KEYS = [\"Shift\", \"Meta\", \"Control\"];\nconst MIDDLE_BUTTON = [1];\nconst FIT_OPTIONS = { padding: 0.2, maxZoom: 1 };\nconst EDITING_TARGET = '[contenteditable=\"true\"],[contenteditable=\"plaintext-only\"],input,textarea,select,[role=\"menu\"],[role=\"dialog\"],[popover]';\n\n/** Anything board-shaped → React Flow's nodes and edges. A sticky or shape\n * that arrives without a size gets its type's default (withFittedSize):\n * its words are fitted to its box, which must not be the box its words\n * made. */\nfunction prepare(board) {\n const { nodes, edges } = readBoard(board);\n return {\n nodes: nodes.map((n) =>\n withFittedSize({ ...n, position: { x: Number(n.position?.x) || 0, y: Number(n.position?.y) || 0 }, data: n.data ?? {}, ...(n.type === \"frame\" && n.zIndex === undefined ? { zIndex: -1 } : null) }),\n ),\n edges: edges.map((e) => ({ ...e, type: e.type ?? \"flow\", data: e.data ?? {} })),\n };\n}\n\n/**\n * A floating toolbar over something on the board, placed in SCREEN space\n * with left/top and no CSS transform (FloatAt): a kit Menu or Tooltip\n * inside it anchors to its button, and CSS anchor positioning is not told\n * about a transformed ancestor such as React Flow's viewport. Only this\n * re-renders as the view pans and zooms.\n */\nfunction Floating({ at, children }) {\n const [tx, ty, z] = useStore((s) => s.transform);\n return (\n <FloatAt x={at.x * z + tx} top={at.top * z + ty - at.lift} bottom={at.bottom * z + ty + 14}>\n {children}\n </FloatAt>\n );\n}\n\n/** The zoom as a percentage; re-renders alone as the view zooms. */\nfunction ZoomLevel() {\n const z = useStore((s) => s.transform[2]);\n return <>{Math.round((z || 1) * 100)}%</>;\n}\n\nfunction usePrefersReducedMotion() {\n const [reduce, setReduce] = useState(false);\n useEffect(() => {\n const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n const on = () => setReduce(mq.matches);\n on();\n mq.addEventListener(\"change\", on);\n return () => mq.removeEventListener(\"change\", on);\n }, []);\n return reduce;\n}\n\nexport function FlowCanvas(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n if (!mounted) return <Placeholder {...props} />;\n return (\n <ReactFlowProvider>\n <LiveCanvas {...props} />\n </ReactFlowProvider>\n );\n}\n\n/** The server's (and the first paint's) board: its box, and its words for\n * a reader. Not interactive. */\nfunction Placeholder({ value, defaultValue, height = 560, bare = false, className, label = \"Board\", describeNode }) {\n const text = boardToText(value ?? defaultValue ?? emptyBoard(), { describe: describeNode });\n return (\n <div role=\"region\" aria-label={label} aria-busy=\"true\" className={cx(\"relative overflow-hidden bg-background\", !bare && \"rounded-lg border border-border\", className)} style={{ height }}>\n <p className=\"sr-only whitespace-pre-line\">{text}</p>\n </div>\n );\n}\n\nfunction LiveCanvas(props) {\n const {\n value,\n defaultValue,\n readOnly: readOnlyProp = false,\n presentation = false,\n onExitPresentation,\n nodeTypes: ownNodeTypes,\n tools: ownTools,\n rail,\n topBar,\n toolbarExtra,\n contextMenuItems,\n emptyState,\n templates: templatesProp,\n renderCard,\n describeNode,\n palette = STICKY_COLORS,\n snap: snapProp = true,\n grid = GRID,\n minimap: minimapProp = false,\n height = 560,\n bare = false,\n label = \"Board\",\n hideAttribution = false,\n className,\n canvasRef,\n } = props;\n const readOnly = readOnlyProp || presentation;\n const rf = useReactFlow();\n const reduce = usePrefersReducedMotion();\n const duration = reduce ? 0 : 220;\n const propsRef = useRef(props);\n propsRef.current = props;\n\n // ── the board ──────────────────────────────────────────────────────────\n const initial = useRef(null);\n if (!initial.current) initial.current = prepare(value ?? defaultValue ?? emptyBoard());\n const [nodes, setNodes] = useState(initial.current.nodes);\n const [edges, setEdges] = useState(initial.current.edges);\n const nodesRef = useRef(nodes);\n const edgesRef = useRef(edges);\n nodesRef.current = nodes;\n edgesRef.current = edges;\n const [history, dispatch] = useReducer(historyReducer, initial.current, historyInit);\n const historyRef = useRef(history);\n historyRef.current = history;\n const lastKey = useRef(boardKey(initial.current));\n const viewportRef = useRef(readBoard(value ?? defaultValue).viewport);\n\n // ── ui state ───────────────────────────────────────────────────────────\n const [tool, setTool] = useState(\"select\");\n const [stickyColor, setStickyColor] = useState(palette[0] ?? \"warning\");\n const [shapeKind, setShapeKind] = useState(\"rect\");\n const [editing, setEditing] = useState({ node: null, edge: null });\n const [connectMenu, setConnectMenu] = useState(null);\n const [listView, setListView] = useState(false);\n const [minimap, setMinimap] = useState(minimapProp);\n const [snap, setSnap] = useState(snapProp);\n const [sheet, setSheet] = useState(false);\n const [save, setSave] = useState({ state: \"idle\" });\n const [draft, setDraft] = useState(null);\n const [narrow, setNarrow] = useState(false);\n const [announce, setAnnounce] = useState(\"\");\n const [slide, setSlide] = useState(0);\n const [ctxOn, setCtxOn] = useState(\"pane\");\n const wrapRef = useRef(null);\n const frameMembers = useRef(new Map());\n const clip = useRef(null);\n const pasteCount = useRef(0);\n const pointer = useRef(null);\n const suppressClick = useRef(false);\n const ctxPoint = useRef(null);\n const connecting = useConnection((c) => c.inProgress);\n const marquee = useStore((s) => s.userSelectionActive);\n const say = (msg) => setAnnounce((m) => (m === msg ? `${msg} ` : msg));\n\n useEffect(() => setSnap(snapProp), [snapProp]);\n useEffect(() => setMinimap(minimapProp), [minimapProp]);\n\n // The rail moves to the bottom when the CANVAS is narrow (not the window).\n useEffect(() => {\n const el = wrapRef.current;\n if (!el || typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(([e]) => setNarrow(e.contentRect.width < 768));\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // ── committing, history, onChange / onSave ─────────────────────────────\n const emitTimer = useRef(0);\n const dirty = useRef(false);\n const saveSeq = useRef(0);\n const currentBoard = () => cleanBoard({ nodes: nodesRef.current, edges: edgesRef.current, viewport: rf.getViewport?.() ?? viewportRef.current });\n\n const runSave = async (board) => {\n const onSave = propsRef.current.onSave;\n if (!onSave) return;\n const seq = ++saveSeq.current;\n setSave({ state: \"saving\" });\n let res;\n try {\n res = await onSave(board);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (seq !== saveSeq.current) return;\n setSave(res && res.ok === false ? { state: \"error\", error: res.error || \"Not saved\" } : { state: \"saved\" });\n };\n const emitNow = () => {\n clearTimeout(emitTimer.current);\n if (!dirty.current) return;\n dirty.current = false;\n const board = currentBoard();\n lastKey.current = boardKey(board);\n propsRef.current.onChange?.(board);\n runSave(board);\n };\n const scheduleEmit = () => {\n dirty.current = true;\n clearTimeout(emitTimer.current);\n emitTimer.current = setTimeout(emitNow, propsRef.current.changeDelay ?? 400);\n };\n // Leaving with a change not yet sent: send it now.\n useEffect(\n () => () => {\n if (dirty.current) emitNow();\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [],\n );\n\n const [tick, setTick] = useState(0);\n const pending = useRef(null);\n const commit = useCallback((reason = \"edit\", ids) => {\n const prev = pending.current;\n pending.current = { reason, ids: [...(prev?.ids ?? []), ...(ids ?? [])] };\n setTick((t) => t + 1);\n }, []);\n\n useEffect(() => {\n const p = pending.current;\n if (!p) return;\n pending.current = null;\n const { constrain, beforeChange } = propsRef.current;\n let ns = nodesRef.current;\n const touched = new Set(p.ids);\n if (touched.size) {\n ns = ns.map((n) => {\n if (!touched.has(n.id)) return n;\n let next = n;\n // A card whose height follows its words drops the height a resize gave it.\n if (p.reason === \"resize\" && AUTO_HEIGHT.has(n.type) && next.height !== undefined) {\n const { height: _h, ...rest } = next;\n next = rest;\n }\n if (constrain) {\n const out = constrain(next, { reason: p.reason, board: { nodes: ns, edges: edgesRef.current } });\n if (out && typeof out === \"object\") next = { ...next, ...out };\n }\n return next;\n });\n }\n let next = { nodes: ns, edges: edgesRef.current };\n if (beforeChange) {\n const out = beforeChange(cleanBoard(next, { viewport: false }), historyRef.current.present, p.reason);\n if (out === false) {\n const back = prepare(historyRef.current.present);\n const sel = new Set(ns.filter((n) => n.selected).map((n) => n.id));\n setNodes(back.nodes.map((n) => (sel.has(n.id) ? { ...n, selected: true } : n)));\n setEdges(back.edges);\n return;\n }\n if (out && typeof out === \"object\") {\n const sel = new Set(ns.filter((n) => n.selected).map((n) => n.id));\n const b = prepare(out);\n next = { nodes: b.nodes.map((n) => (sel.has(n.id) ? { ...n, selected: true } : n)), edges: b.edges };\n }\n }\n if (next.nodes !== nodesRef.current) setNodes(next.nodes);\n if (next.edges !== edgesRef.current) setEdges(next.edges);\n const before = historyRef.current.key;\n dispatch({ type: \"push\", present: next });\n if (boardKey(next) !== before) scheduleEmit();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [tick]);\n\n const restore = (board) => {\n const b = prepare(board);\n setNodes(b.nodes);\n setEdges(b.edges);\n setEditing({ node: null, edge: null });\n };\n const undo = () => {\n const h = historyRef.current;\n if (!h.past.length) return;\n restore(h.past[h.past.length - 1]);\n dispatch({ type: \"undo\" });\n scheduleEmit();\n say(\"Undone\");\n };\n const redo = () => {\n const h = historyRef.current;\n if (!h.future.length) return;\n restore(h.future[0]);\n dispatch({ type: \"redo\" });\n scheduleEmit();\n say(\"Redone\");\n };\n\n // Controlled: a `value` that is not what we last sent replaces the board\n // (and can be undone).\n useEffect(() => {\n if (value === undefined) return;\n const k = boardKey(value);\n if (k === lastKey.current) return;\n lastKey.current = k;\n const b = prepare(value);\n const sel = new Set(nodesRef.current.filter((n) => n.selected).map((n) => n.id));\n setNodes(b.nodes.map((n) => (sel.has(n.id) ? { ...n, selected: true } : n)));\n setEdges(b.edges);\n dispatch({ type: \"push\", present: b });\n }, [value]);\n\n // ── changing the board ─────────────────────────────────────────────────\n const selectOnly = (ids = [], edgeIds = []) => {\n const want = new Set(ids);\n const wantE = new Set(edgeIds);\n setNodes((ns) => ns.map((n) => (Boolean(n.selected) === want.has(n.id) ? n : { ...n, selected: want.has(n.id) })));\n setEdges((es) => es.map((e) => (Boolean(e.selected) === wantE.has(e.id) ? e : { ...e, selected: wantE.has(e.id) })));\n };\n\n const addNodes = (list, { select = true, edit = false, reason = \"create\", edges: newEdges } = {}) => {\n const arr = (Array.isArray(list) ? list : [list]).filter(Boolean).map((n) => ({\n ...n,\n id: n.id ?? makeId(n.type ?? \"n\"),\n position: n.position ?? { x: 0, y: 0 },\n data: n.data ?? {},\n ...(n.type === \"frame\" && n.zIndex === undefined ? { zIndex: -1 } : null),\n selected: select,\n }));\n if (!arr.length) return [];\n setNodes((ns) => [...(select ? ns.map((n) => (n.selected ? { ...n, selected: false } : n)) : ns), ...arr]);\n if (newEdges?.length) setEdges((es) => [...es.map((e) => (e.selected ? { ...e, selected: false } : e)), ...newEdges.map((e) => ({ ...e, type: e.type ?? \"flow\", data: e.data ?? {} }))]);\n commit(reason, arr.map((n) => n.id));\n if (edit && arr.length === 1) setEditing({ node: arr[0].id, edge: null });\n return arr.map((n) => n.id);\n };\n\n const updateNodes = (ids, fn, { commit: doCommit = true, reason = \"edit\" } = {}) => {\n const set = new Set(ids);\n setNodes((ns) => ns.map((n) => (set.has(n.id) ? fn(n) : n)));\n if (doCommit) commit(reason);\n };\n\n const removeNodes = (ids) => {\n const gone = new Set(ids);\n if (!gone.size) return;\n setNodes((ns) => ns.filter((n) => !gone.has(n.id)));\n setEdges((es) => es.filter((e) => !gone.has(e.source) && !gone.has(e.target)));\n setEditing({ node: null, edge: null });\n commit(\"delete\");\n say(`${gone.size} item${gone.size === 1 ? \"\" : \"s\"} deleted`);\n };\n\n const removeEdges = (ids) => {\n const gone = new Set(ids);\n setEdges((es) => es.filter((e) => !gone.has(e.id)));\n setEditing((ed) => (gone.has(ed.edge) ? { node: null, edge: null } : ed));\n commit(\"delete\");\n };\n\n const deleteSelection = () => {\n const ids = nodesRef.current.filter((n) => n.selected && n.deletable !== false && !n.data?.locked).map((n) => n.id);\n const eids = edgesRef.current.filter((e) => e.selected && e.deletable !== false).map((e) => e.id);\n if (!ids.length && !eids.length) return;\n const gone = new Set(ids);\n const goneE = new Set(eids);\n setNodes((ns) => ns.filter((n) => !gone.has(n.id)));\n setEdges((es) => es.filter((e) => !goneE.has(e.id) && !gone.has(e.source) && !gone.has(e.target)));\n commit(\"delete\");\n say(`${ids.length + eids.length} deleted`);\n };\n\n const duplicate = (ids) => {\n const src = { nodes: nodesRef.current, edges: edgesRef.current };\n const copy = duplicateNodes(src, ids.filter((id) => nodesRef.current.some((n) => n.id === id)), { offset: { x: grid * 2, y: grid * 2 } });\n if (!copy.nodes.length) return;\n addNodes(copy.nodes, { reason: \"paste\", edges: copy.edges });\n say(`${copy.nodes.length} duplicated`);\n };\n\n const pasteBoard = (data, at) => {\n const b = readBoard(data);\n if (!b.nodes.length) return;\n const bounds = boardBounds(b.nodes);\n pasteCount.current += 1;\n const offset = at\n ? { x: Math.round((at.x - bounds.x - bounds.width / 2) / grid) * grid, y: Math.round((at.y - bounds.y - bounds.height / 2) / grid) * grid }\n : { x: grid * 2 * pasteCount.current, y: grid * 2 * pasteCount.current };\n const copy = duplicateNodes(b, b.nodes.map((n) => n.id), { offset });\n addNodes(copy.nodes, { reason: \"paste\", edges: copy.edges });\n say(`${copy.nodes.length} pasted`);\n };\n\n const lock = (ids, on = true) => {\n updateNodes(ids, (n) => {\n const data = { ...n.data };\n if (on) data.locked = true;\n else delete data.locked;\n const next = { ...n, data };\n if (on) {\n next.draggable = false;\n next.deletable = false;\n } else {\n delete next.draggable;\n delete next.deletable;\n }\n return next;\n });\n say(on ? \"Locked\" : \"Unlocked\");\n };\n\n const frameSelection = (ids) => {\n const pick = nodesRef.current.filter((n) => ids.includes(n.id));\n const b = boardBounds(pick);\n if (!b) return;\n const pad = grid * 2;\n const f = createNode(\"frame\", null, { rect: { x: b.x - pad, y: b.y - pad, width: b.width + pad * 2, height: b.height + pad * 2 }, grid, data: { title: \"Frame\" } });\n addNodes(f, { edit: true });\n };\n\n const fitView = (opts) => rf.fitView({ padding: 0.2, maxZoom: 1.25, duration, ...opts });\n const focusNode = (id) => {\n selectOnly([id]);\n const n = nodesRef.current.find((x) => x.id === id);\n if (!n) return;\n const r = nodeRect(n);\n rf.setCenter(r.x + r.width / 2, r.y + r.height / 2, { zoom: Math.max(rf.getZoom(), 0.8), duration });\n requestAnimationFrame(() => wrapRef.current?.querySelector(`.react-flow__node[data-id=\"${CSS.escape(id)}\"]`)?.focus({ preventScroll: true }));\n };\n const centerPoint = () => {\n const r = wrapRef.current?.getBoundingClientRect();\n if (!r) return { x: 0, y: 0 };\n return rf.screenToFlowPosition({ x: r.left + r.width / 2, y: r.top + r.height / 2 });\n };\n\n // The handle. One object for the canvas's life: every method reads the\n // latest state through refs, so a block may keep it.\n const apiRef = useRef(null);\n if (!apiRef.current) apiRef.current = {};\n const api = apiRef.current;\n Object.assign(api, {\n getBoard: currentBoard,\n setBoard: (board, { fit = true } = {}) => {\n restore(board);\n commit(\"load\");\n if (fit) setTimeout(() => fitView(), 60);\n },\n getSelection: () => ({ nodes: nodesRef.current.filter((n) => n.selected), edges: edgesRef.current.filter((e) => e.selected) }),\n addNodes,\n addEdge: (edge) => {\n const e = edge.id ? { type: \"flow\", data: {}, ...edge } : createEdge(edge.source, edge.target, edge);\n setEdges((es) => [...es, e]);\n commit(\"connect\");\n return e.id;\n },\n updateNode: (id, patch, opts) => updateNodes([id], (n) => ({ ...n, ...(typeof patch === \"function\" ? patch(n) : patch) }), opts),\n updateNodeData: (id, patch, opts) => updateNodes([id], (n) => ({ ...n, data: { ...n.data, ...(typeof patch === \"function\" ? patch(n.data) : patch) } }), opts),\n updateNodesData: (ids, patch, opts) => updateNodes(ids, (n) => ({ ...n, data: { ...n.data, ...patch } }), opts),\n removeNodes,\n updateEdge: (id, patch, { commit: doCommit = true } = {}) => {\n setEdges((es) => es.map((e) => (e.id === id ? { ...e, ...(typeof patch === \"function\" ? patch(e) : patch) } : e)));\n if (doCommit) commit(\"edit\");\n },\n removeEdges,\n select: (ids, edgeIds) => selectOnly(ids, edgeIds),\n duplicate,\n lock,\n arrange: (ids, where) => {\n setNodes((ns) => arrangeNodes(ns, ids, where));\n commit(\"arrange\");\n },\n align: (ids, how) => {\n setNodes((ns) => alignNodes(ns, ids, how));\n commit(\"align\", ids);\n },\n distribute: (ids, axis) => {\n setNodes((ns) => distributeNodes(ns, ids, axis));\n commit(\"align\", ids);\n },\n autoLayout: (opts = {}) => {\n setNodes((ns) => autoLayout(ns, edgesRef.current, { grid, ...opts }));\n commit(\"layout\", opts.ids ?? nodesRef.current.map((n) => n.id));\n if (!opts.ids) setTimeout(() => fitView(), 60);\n },\n frameSelection,\n edit: (id) => setEditing({ node: id, edge: null }),\n editEdge: (id) => setEditing({ node: null, edge: id }),\n stopEditing: () => {\n setEditing({ node: null, edge: null });\n // Words emptied on a connector label go away with it.\n setEdges((es) => es.map((e) => (typeof e.label === \"string\" && !e.label.trim() ? { ...e, label: undefined } : e)));\n commit(\"edit\");\n requestAnimationFrame(() => {\n if (!wrapRef.current?.contains(document.activeElement)) wrapRef.current?.focus({ preventScroll: true });\n });\n },\n commit,\n undo,\n redo,\n canUndo: () => historyRef.current.past.length > 0,\n canRedo: () => historyRef.current.future.length > 0,\n setTool: (id) => setTool(id),\n getTool: () => tool,\n fitView,\n focusNode,\n zoomIn: () => rf.zoomIn({ duration }),\n zoomOut: () => rf.zoomOut({ duration }),\n zoomTo: (z) => rf.zoomTo(z, { duration }),\n centerPoint,\n screenToFlow: (p) => rf.screenToFlowPosition(p),\n toText: () => boardToText(currentBoard(), { describe: propsRef.current.describeNode }),\n toMarkdown: (opts) => boardToMarkdown(currentBoard(), { describe: propsRef.current.describeNode, ...opts }),\n save: () => {\n dirty.current = true;\n emitNow();\n },\n readOnly,\n });\n\n useEffect(() => {\n if (!canvasRef) return;\n canvasRef.current = api;\n return () => {\n if (canvasRef.current === api) canvasRef.current = null;\n };\n }, [canvasRef, api]);\n\n // ── tools ──────────────────────────────────────────────────────────────\n const customs = useMemo(\n () =>\n (ownTools ?? []).map((t) => ({ ...t, kind: t.onSelect && !t.create ? \"action\" : \"mode\", custom: true })),\n [ownTools],\n );\n const toolById = useMemo(() => {\n const m = new Map(Object.values(TOOLS).map((t) => [t.id, t]));\n for (const t of customs) m.set(t.id, t);\n return m;\n }, [customs]);\n const railItems = useMemo(() => {\n const ids = rail ?? [...DEFAULT_RAIL, ...customs.map((t) => t.id), ...(props.pickImage ? [\"image\"] : [])];\n return ids.map((id) => (id === \"|\" ? \"|\" : toolById.get(id))).filter(Boolean);\n }, [rail, customs, toolById, props.pickImage]);\n const active = toolById.get(tool) ?? TOOLS.select;\n const placing = Boolean(active.place || active.create);\n const cursorTool = tool === \"hand\" ? \"hand\" : tool === \"connector\" ? \"connector\" : tool === \"text\" ? \"text\" : placing ? \"place\" : \"select\";\n\n const pickTool = (t) => {\n if (!t || readOnly) return;\n setConnectMenu(null);\n if (t.id === \"image\") {\n Promise.resolve(propsRef.current.pickImage?.())\n .then((img) => {\n if (!img?.src) return;\n addNodes(createNode(\"image\", centerPoint(), { grid, data: { src: img.src, alt: img.alt ?? \"\" }, width: img.width, height: img.height }));\n })\n .catch(() => {});\n return;\n }\n if (t.kind === \"action\") {\n t.onSelect?.(api);\n return;\n }\n setTool((cur) => (cur === t.id && t.id !== \"select\" ? \"select\" : t.id));\n if (t.id !== \"select\") selectOnly([]);\n };\n\n /** Put down what a tool makes, at a board point (or filling a rect). */\n const place = (t, point, rect) => {\n let made;\n if (t.create) {\n made = t.create(point, { rect, api, grid });\n } else if (t.place) {\n const data = t.place === \"sticky\" ? { color: stickyColor } : t.place === \"shape\" ? { shape: shapeKind } : undefined;\n made = createNode(t.place, point, { grid: snap ? grid : 0, data, rect: t.place === \"text\" && rect ? { ...rect, height: undefined } : rect });\n if (t.place === \"text\" && rect) delete made.height;\n }\n const list = (Array.isArray(made) ? made : [made]).filter(Boolean);\n if (!list.length) return;\n const one = list.length === 1 ? list[0] : null;\n addNodes(list, { edit: Boolean(one && EDITABLE.has(one.type)) });\n setTool(\"select\");\n say(`${t.label} added`);\n };\n\n // ── keyboard ───────────────────────────────────────────────────────────\n const nudge = useRef(0);\n const selectedNodes = nodes.filter((n) => n.selected);\n const selectedEdges = edges.filter((e) => e.selected);\n\n const frames = useMemo(\n () =>\n nodes\n .filter((n) => n.type === \"frame\")\n .sort((a, b) => {\n const ra = nodeRect(a);\n const rb = nodeRect(b);\n return Math.abs(ra.y - rb.y) > 40 ? ra.y - rb.y : ra.x - rb.x;\n }),\n [nodes],\n );\n const goSlide = (i) => {\n const f = frames[Math.max(0, Math.min(frames.length - 1, i))];\n if (!f) return;\n setSlide(frames.indexOf(f));\n rf.fitView({ nodes: [{ id: f.id }], padding: 0.08, duration: reduce ? 0 : 400 });\n };\n useEffect(() => {\n if (!presentation) return;\n const t = setTimeout(() => (frames.length ? goSlide(0) : fitView()), 80);\n return () => clearTimeout(t);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [presentation]);\n\n const onKeyDownCapture = (e) => {\n const t = e.target;\n if (t?.closest?.(EDITING_TARGET)) return;\n const mod = e.metaKey || e.ctrlKey;\n const k = e.key;\n const low = k.length === 1 ? k.toLowerCase() : k;\n const onControl = Boolean(t?.closest?.(\"button, a[href]\"));\n const done = () => {\n e.preventDefault();\n e.stopPropagation();\n };\n\n // View keys work everywhere, read-only too.\n if (e.shiftKey && !mod && (e.code === \"Digit1\" || k === \"!\")) return done(), fitView();\n if (e.shiftKey && !mod && (e.code === \"Digit2\" || k === \"@\")) {\n done();\n const sel = nodesRef.current.filter((n) => n.selected);\n return sel.length ? fitView({ nodes: sel.map((n) => ({ id: n.id })), maxZoom: 2 }) : fitView();\n }\n if ((mod && (k === \"=\" || k === \"+\")) || (!mod && !onControl && (k === \"+\" || k === \"=\"))) return done(), rf.zoomIn({ duration });\n if ((mod && k === \"-\") || (!mod && !onControl && k === \"-\")) return done(), rf.zoomOut({ duration });\n if (mod && k === \"0\") return done(), rf.zoomTo(1, { duration });\n if (k === \"?\" && !mod) return done(), setSheet(true);\n if (presentation) {\n if (k === \"ArrowRight\" || k === \"PageDown\" || (k === \" \" && !onControl)) return done(), goSlide(slide + 1);\n if (k === \"ArrowLeft\" || k === \"PageUp\") return done(), goSlide(slide - 1);\n if (k === \"Escape\") return done(), propsRef.current.onExitPresentation?.();\n return;\n }\n if (k === \"Escape\") {\n if (editing.node || editing.edge) return done(), setEditing({ node: null, edge: null });\n if (connectMenu) return done(), setConnectMenu(null);\n if (listView) return done(), setListView(false);\n if (tool !== \"select\") return done(), setTool(\"select\");\n if (selectedNodes.length || selectedEdges.length) return done(), selectOnly([]);\n return;\n }\n if (readOnly) return;\n\n if (mod && low === \"z\") return done(), e.shiftKey ? redo() : undo();\n if (mod && low === \"y\") return done(), redo();\n if (mod && low === \"s\") return done(), api.save();\n if (mod && low === \"a\") return done(), selectOnly(nodesRef.current.filter((n) => n.selectable !== false).map((n) => n.id));\n const ids = nodesRef.current.filter((n) => n.selected).map((n) => n.id);\n if (mod && low === \"d\") return done(), ids.length && duplicate(ids);\n if (mod && e.shiftKey && low === \"l\") return done(), ids.length && lock(ids, !nodesRef.current.some((n) => n.selected && n.data?.locked));\n if (mod && k === \"]\") return done(), ids.length && api.arrange(ids, \"front\");\n if (mod && k === \"[\") return done(), ids.length && api.arrange(ids, \"back\");\n if (mod) return;\n\n if (!onControl && (k === \"Delete\" || k === \"Backspace\")) return done(), deleteSelection();\n if (!onControl && k === \"Enter\") {\n const focused = t?.classList?.contains(\"react-flow__node\") ? t.dataset.id : null;\n if (focused && !nodesRef.current.find((n) => n.id === focused)?.selected) return done(), selectOnly([focused]);\n if (ids.length === 1) {\n const n = nodesRef.current.find((x) => x.id === ids[0]);\n if (n && !n.data?.locked) return done(), openNode(n);\n }\n const eid = edgesRef.current.filter((x) => x.selected).map((x) => x.id);\n if (eid.length === 1) return done(), setEditing({ node: null, edge: eid[0] });\n return;\n }\n if (!onControl && k.startsWith(\"Arrow\") && ids.length) {\n done();\n const step = e.shiftKey ? 10 : 1;\n const dx = k === \"ArrowLeft\" ? -step : k === \"ArrowRight\" ? step : 0;\n const dy = k === \"ArrowUp\" ? -step : k === \"ArrowDown\" ? step : 0;\n const movable = nodesRef.current.filter((n) => n.selected && n.draggable !== false && !n.data?.locked).map((n) => n.id);\n updateNodes(movable, (n) => ({ ...n, position: { x: n.position.x + dx, y: n.position.y + dy } }), { commit: false });\n clearTimeout(nudge.current);\n nudge.current = setTimeout(() => commit(\"move\", movable), 400);\n return;\n }\n if (!e.altKey && k.length === 1) {\n const hit = railItems.find((x) => x !== \"|\" && x.shortcut && x.shortcut.toLowerCase() === low);\n if (hit) return done(), pickTool(hit);\n }\n };\n\n // Tab onto a card selects it, so what the keys do is what is shown.\n const onFocusCapture = (e) => {\n const el = e.target;\n if (!el?.classList?.contains(\"react-flow__node\") || !el.matches(\":focus-visible\")) return;\n const id = el.dataset.id;\n const n = nodesRef.current.find((x) => x.id === id);\n if (n && !n.selected && n.selectable !== false) selectOnly([id]);\n };\n\n // Copy, cut, paste: the board's own clipboard, with the text on the\n // system one too (paste a list of lines from elsewhere = one sticky each).\n useEffect(() => {\n const MIME = \"application/x-flow-canvas+json\";\n const activeHere = () => {\n const a = document.activeElement;\n return wrapRef.current?.contains(a) && !a?.closest?.(EDITING_TARGET);\n };\n const copy = (e) => {\n if (!activeHere()) return false;\n const ids = nodesRef.current.filter((n) => n.selected).map((n) => n.id);\n if (!ids.length) return false;\n const all = new Set(ids);\n for (const id of ids) if (nodesRef.current.find((n) => n.id === id)?.type === \"frame\") for (const c of frameContents({ nodes: nodesRef.current }, id)) all.add(c);\n const data = cleanBoard({ nodes: nodesRef.current.filter((n) => all.has(n.id)), edges: edgesRef.current.filter((x) => all.has(x.source) && all.has(x.target)) }, { viewport: false });\n clip.current = data;\n pasteCount.current = 0;\n const text = boardToText(data, { describe: propsRef.current.describeNode });\n clip.current.text = text;\n e.clipboardData?.setData(\"text/plain\", text);\n try {\n e.clipboardData?.setData(MIME, JSON.stringify(data));\n } catch {}\n e.preventDefault();\n return true;\n };\n const onCopy = (e) => copy(e);\n const onCut = (e) => {\n if (propsRef.current.readOnly || propsRef.current.presentation) return;\n if (copy(e)) deleteSelection();\n };\n const onPaste = (e) => {\n if (!activeHere() || propsRef.current.readOnly || propsRef.current.presentation) return;\n let data = null;\n try {\n const raw = e.clipboardData?.getData(MIME);\n if (raw) data = JSON.parse(raw);\n } catch {}\n const text = e.clipboardData?.getData(\"text/plain\") ?? \"\";\n if (!data && clip.current && text === clip.current.text) data = clip.current;\n e.preventDefault();\n if (data) return pasteBoard(data, pointer.current);\n const lines = text\n .split(/\\r?\\n/)\n .map((s) => s.replace(/^\\s*(?:[-*•]|\\d+[.)])\\s+/, \"\").trim())\n .filter(Boolean)\n .slice(0, 24);\n if (!lines.length) return;\n const at = pointer.current ?? centerPoint();\n const cols = Math.min(4, lines.length);\n const made = lines.map((line, i) =>\n createNode(\"sticky\", { x: at.x + (i % cols) * 220 - ((cols - 1) * 220) / 2, y: at.y + Math.floor(i / cols) * 220 }, { grid, data: { text: line, color: stickyColor } }),\n );\n addNodes(made, { reason: \"paste\" });\n };\n document.addEventListener(\"copy\", onCopy);\n document.addEventListener(\"cut\", onCut);\n document.addEventListener(\"paste\", onPaste);\n return () => {\n document.removeEventListener(\"copy\", onCopy);\n document.removeEventListener(\"cut\", onCut);\n document.removeEventListener(\"paste\", onPaste);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [stickyColor]);\n\n // ── pointer: focus, double-click, drag-to-size ─────────────────────────\n const isPane = (el) => el?.classList?.contains(\"react-flow__pane\");\n const onPointerDownCapture = (e) => {\n const a = document.activeElement;\n if (!e.target.closest?.(EDITING_TARGET) && !wrapRef.current?.contains(a)) wrapRef.current?.focus({ preventScroll: true });\n if (!isPane(e.target) || e.button !== 0 || readOnly) return;\n if (connectMenu) setConnectMenu(null);\n if (!active.sizable) return;\n const start = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });\n const origin = { x: e.clientX, y: e.clientY };\n let moved = false;\n const rectFrom = (ev) => {\n const p = rf.screenToFlowPosition({ x: ev.clientX, y: ev.clientY });\n const s = snap ? grid : 1;\n const x1 = Math.round(Math.min(start.x, p.x) / s) * s;\n const y1 = Math.round(Math.min(start.y, p.y) / s) * s;\n const x2 = Math.round(Math.max(start.x, p.x) / s) * s;\n const y2 = Math.round(Math.max(start.y, p.y) / s) * s;\n return { x: x1, y: y1, width: Math.max(s, x2 - x1), height: Math.max(s, y2 - y1) };\n };\n const move = (ev) => {\n if (!moved && Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) < 5) return;\n moved = true;\n setDraft(rectFrom(ev));\n };\n const up = (ev) => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n setDraft(null);\n if (!moved) return;\n suppressClick.current = true;\n const r = rectFrom(ev);\n if (r.width < grid * 2 && r.height < grid * 2) return;\n place(active, { x: r.x + r.width / 2, y: r.y + r.height / 2 }, r);\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n };\n\n const onPaneClick = (e) => {\n setConnectMenu(null);\n if (editing.node || editing.edge) setEditing({ node: null, edge: null });\n if (suppressClick.current) {\n suppressClick.current = false;\n return;\n }\n if (readOnly || !placing) return;\n place(active, rf.screenToFlowPosition({ x: e.clientX, y: e.clientY }));\n };\n\n const onDoubleClick = (e) => {\n if (readOnly || tool !== \"select\" || !isPane(e.target)) return;\n const p = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });\n const n = createNode(\"sticky\", p, { grid: snap ? grid : 0, data: { color: stickyColor } });\n addNodes(n, { edit: true });\n say(\"Sticky added\");\n };\n\n const openNode = (n) => {\n const { onOpenNode } = propsRef.current;\n const custom = !BUILT_IN_NODES[n.type];\n if (onOpenNode && (custom || (n.type === \"card\" && renderCard) || readOnly)) return onOpenNode(n);\n if (readOnly || n.data?.locked) return;\n if (EDITABLE.has(n.type) || custom) setEditing({ node: n.id, edge: null });\n };\n\n // ── React Flow wiring ──────────────────────────────────────────────────\n const onNodesChange = useCallback((changes) => {\n const real = changes.filter((c) => c.type !== \"remove\");\n setNodes((nds) => {\n const members = frameMembers.current;\n if (!members.size) return applyNodeChanges(real, nds);\n const byId = new Map(nds.map((n) => [n.id, n]));\n const moving = new Set(real.filter((c) => c.type === \"position\").map((c) => c.id));\n const extra = [];\n for (const c of real) {\n if (c.type !== \"position\" || !c.position || !members.has(c.id)) continue;\n const prev = byId.get(c.id);\n if (!prev) continue;\n const dx = c.position.x - prev.position.x;\n const dy = c.position.y - prev.position.y;\n if (!dx && !dy) continue;\n for (const m of members.get(c.id)) {\n const mn = byId.get(m);\n if (!mn || moving.has(m)) continue;\n moving.add(m);\n extra.push({ type: \"position\", id: m, position: { x: mn.position.x + dx, y: mn.position.y + dy } });\n }\n }\n return applyNodeChanges(extra.length ? [...real, ...extra] : real, nds);\n });\n }, []);\n const onEdgesChange = useCallback((changes) => setEdges((es) => applyEdgeChanges(changes.filter((c) => c.type !== \"remove\"), es)), []);\n\n const dragStart = (dragged) => {\n setConnectMenu(null);\n const ids = new Set((dragged ?? []).map((n) => n.id));\n const m = new Map();\n for (const n of dragged ?? []) {\n if (n.type !== \"frame\") continue;\n const inside = frameContents({ nodes: nodesRef.current }, n.id).filter((id) => !ids.has(id));\n if (inside.length) m.set(n.id, inside);\n }\n frameMembers.current = m;\n };\n const dragStop = (dragged) => {\n const ids = (dragged ?? []).map((n) => n.id);\n for (const list of frameMembers.current.values()) ids.push(...list);\n frameMembers.current = new Map();\n commit(\"move\", ids);\n };\n\n const isValidConnection = (c) => {\n if (c.source === c.target) return false;\n const can = propsRef.current.canConnect;\n return can ? can(c, { nodes: nodesRef.current, edges: edgesRef.current }) !== false : true;\n };\n const onConnect = (c) => {\n if (!isValidConnection(c)) return;\n if (edgesRef.current.some((e) => e.source === c.source && e.target === c.target)) return;\n const e = createEdge(c.source, c.target);\n setEdges((es) => [...es, e]);\n commit(\"connect\");\n say(\"Connected\");\n };\n const onConnectEnd = (event, state) => {\n if (readOnly || state?.isValid || state?.toNode || !state?.fromNode) return;\n const pt = \"changedTouches\" in event && event.changedTouches?.length ? event.changedTouches[0] : event;\n const box = wrapRef.current?.getBoundingClientRect();\n if (!box) return;\n const flow = rf.screenToFlowPosition({ x: pt.clientX, y: pt.clientY });\n setConnectMenu({ at: { x: Math.min(pt.clientX - box.left, box.width - 200), y: Math.min(pt.clientY - box.top, box.height - 240) }, flow, from: state.fromNode.id });\n };\n\n const connectItems = useMemo(() => {\n if (!connectMenu) return [];\n const { flow, from } = connectMenu;\n const make = (node) => {\n if (!node) return;\n const list = Array.isArray(node) ? node : [node];\n const first = list[0];\n addNodes(list, {\n edges: first ? [createEdge(from, first.id)] : [],\n edit: list.length === 1 && EDITABLE.has(first?.type),\n });\n setConnectMenu(null);\n };\n const built = [\n { label: \"Sticky note\", icon: \"sticky-note\", onSelect: () => make(createNode(\"sticky\", flow, { grid, data: { color: stickyColor } })) },\n ...Object.entries(SHAPE_ICON).map(([k, icon]) => ({ label: SHAPE_NAMES[k], icon, onSelect: () => make(createNode(\"shape\", flow, { grid, data: { shape: k } })) })),\n { label: \"Text\", icon: \"type\", onSelect: () => make(createNode(\"text\", flow, { grid })) },\n ];\n const own = customs.filter((t) => t.create && t.connectable !== false).map((t) => ({ label: t.label, icon: t.icon, onSelect: () => make(t.create(flow, { api, grid })) }));\n return [...built, own.length ? { type: \"separator\" } : null, ...own];\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [connectMenu, customs, stickyColor]);\n\n // ── the context menu (right-click, long press, Shift+F10) ───────────────\n const ctxItems = (() => {\n const m = modKey();\n if (readOnly) {\n return [\n { label: \"Fit to screen\", icon: \"maximize\", shortcut: \"⇧1\", onSelect: () => fitView() },\n { label: \"List view\", icon: \"list\", onSelect: () => setListView(true) },\n ];\n }\n const sel = selectedNodes;\n if (ctxOn === \"edge\" && selectedEdges.length) {\n const ids = selectedEdges.map((x) => x.id);\n return [\n ids.length === 1 && { label: selectedEdges[0].label ? \"Edit label\" : \"Add label\", icon: \"pencil\", shortcut: \"↵\", onSelect: () => api.editEdge(ids[0]) },\n { label: \"Delete\", icon: \"trash\", tone: \"danger\", shortcut: \"⌫\", onSelect: () => removeEdges(ids) },\n ];\n }\n if (ctxOn === \"selection\" && sel.length) {\n const ids = sel.map((n) => n.id);\n const anyLocked = sel.some((n) => n.data?.locked);\n const single = sel.length === 1 ? sel[0] : null;\n const extra = typeof contextMenuItems === \"function\" ? contextMenuItems({ nodes: sel, edges: selectedEdges }, api) : null;\n return [\n single && !anyLocked && { label: \"Edit\", icon: \"pencil\", shortcut: \"↵\", onSelect: () => openNode(single) },\n { label: \"Duplicate\", icon: \"copy\", shortcut: `${m}D`, onSelect: () => duplicate(ids) },\n !sel.every((n) => n.type === \"frame\") && { label: \"Frame selection\", icon: \"frame\", onSelect: () => frameSelection(ids) },\n { type: \"separator\" },\n { label: \"Bring to front\", icon: \"bring-front\", shortcut: `${m}]`, onSelect: () => api.arrange(ids, \"front\") },\n { label: \"Send to back\", icon: \"send-back\", shortcut: `${m}[`, onSelect: () => api.arrange(ids, \"back\") },\n sel.length > 1 && {\n label: \"Align\",\n icon: \"align-center\",\n items: [\n { label: \"Left\", onSelect: () => api.align(ids, \"left\") },\n { label: \"Centre\", onSelect: () => api.align(ids, \"center\") },\n { label: \"Right\", onSelect: () => api.align(ids, \"right\") },\n { type: \"separator\" },\n { label: \"Top\", onSelect: () => api.align(ids, \"top\") },\n { label: \"Middle\", onSelect: () => api.align(ids, \"middle\") },\n { label: \"Bottom\", onSelect: () => api.align(ids, \"bottom\") },\n sel.length > 2 && { type: \"separator\" },\n sel.length > 2 && { label: \"Distribute horizontally\", onSelect: () => api.distribute(ids, \"horizontal\") },\n sel.length > 2 && { label: \"Distribute vertically\", onSelect: () => api.distribute(ids, \"vertical\") },\n { type: \"separator\" },\n { label: \"Tidy up\", onSelect: () => api.autoLayout({ ids }) },\n ],\n },\n { label: anyLocked ? \"Unlock\" : \"Lock\", icon: anyLocked ? \"lock-open\" : \"lock\", shortcut: `⇧${m}L`, onSelect: () => lock(ids, !anyLocked) },\n ...(extra?.length ? [{ type: \"separator\" }, ...extra] : []),\n { type: \"separator\" },\n { label: \"Delete\", icon: \"trash\", tone: \"danger\", shortcut: \"⌫\", disabled: sel.every((n) => n.data?.locked || n.deletable === false), onSelect: deleteSelection },\n ];\n }\n const at = () => ctxPoint.current ?? centerPoint();\n const extra = typeof contextMenuItems === \"function\" ? contextMenuItems({ nodes: [], edges: [] }, api) : null;\n return [\n { label: \"Add sticky here\", icon: \"sticky-note\", onSelect: () => addNodes(createNode(\"sticky\", at(), { grid, data: { color: stickyColor } }), { edit: true }) },\n { label: \"Add shape here\", icon: \"square\", onSelect: () => addNodes(createNode(\"shape\", at(), { grid, data: { shape: shapeKind } }), { edit: true }) },\n { label: \"Add text here\", icon: \"type\", onSelect: () => addNodes(createNode(\"text\", at(), { grid }), { edit: true }) },\n { label: \"Add frame here\", icon: \"frame\", onSelect: () => addNodes(createNode(\"frame\", at(), { grid }), { edit: true }) },\n { type: \"separator\" },\n clip.current && { label: \"Paste here\", icon: \"copy\", shortcut: `${m}V`, onSelect: () => pasteBoard(clip.current, at()) },\n nodes.length > 0 && { label: \"Select all\", shortcut: `${m}A`, onSelect: () => selectOnly(nodesRef.current.map((n) => n.id)) },\n nodes.length > 1 && { label: \"Tidy up everything\", icon: \"sparkles\", onSelect: () => api.autoLayout() },\n ...(extra?.length ? [{ type: \"separator\" }, ...extra] : []),\n { type: \"separator\" },\n { label: \"Fit to screen\", icon: \"maximize\", shortcut: \"⇧1\", onSelect: () => fitView() },\n { type: \"checkbox\", label: \"Snap to grid\", checked: snap, onCheckedChange: setSnap },\n { label: \"Keyboard shortcuts\", icon: \"keyboard\", shortcut: \"?\", onSelect: () => setSheet(true) },\n ];\n })();\n\n // ── node and edge types, context ───────────────────────────────────────\n const nodeTypes = useMemo(() => ({ ...BUILT_IN_NODES, ...(ownNodeTypes ?? {}) }), [ownNodeTypes]);\n const soleEdge = selectedEdges.length === 1 && !selectedNodes.length ? selectedEdges[0].id : null;\n const ctx = useMemo(\n () => ({ api, tool, editingNode: editing.node, editingEdge: editing.edge, soleEdge, readOnly, palette, renderCard, connecting, grid }),\n [api, tool, editing.node, editing.edge, soleEdge, readOnly, palette, renderCard, connecting, grid],\n );\n\n // React Flow's event props, one stable function each: a new function per\n // render would redraw every node on every render (a pan, a keystroke).\n const live = useRef({});\n live.current = {\n onConnect,\n onConnectEnd,\n isValidConnection,\n onNodeDragStart: (_, __, dragged) => dragStart(dragged),\n onNodeDragStop: (_, __, dragged) => dragStop(dragged),\n onSelectionDragStart: (_, dragged) => dragStart(dragged),\n onSelectionDragStop: (_, dragged) => dragStop(dragged),\n onNodeDoubleClick: (e, n) => {\n e.stopPropagation();\n openNode(n);\n },\n onEdgeDoubleClick: (e, ed) => {\n e.stopPropagation();\n if (!readOnly) setEditing({ node: null, edge: ed.id });\n },\n onPaneClick,\n onPaneContextMenu: (e) => {\n ctxPoint.current = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });\n setCtxOn(\"pane\");\n },\n onNodeContextMenu: (_, n) => {\n if (!n.selected) selectOnly([n.id]);\n setCtxOn(\"selection\");\n },\n onSelectionContextMenu: () => setCtxOn(\"selection\"),\n onEdgeContextMenu: (_, ed) => {\n if (!ed.selected) selectOnly([], [ed.id]);\n setCtxOn(\"edge\");\n },\n onSelectionChange: ({ nodes: sn, edges: se }) => propsRef.current.onSelectionChange?.(sn, se),\n onMoveEnd: (_, vp) => {\n viewportRef.current = vp;\n },\n };\n const handlers = useMemo(() => Object.fromEntries(Object.keys(live.current).map((k) => [k, (...a) => live.current[k](...a)])), []);\n\n const dragging = nodes.some((n) => n.dragging) || marquee;\n const snapGrid = useMemo(() => [grid, grid], [grid]);\n const proOptions = useMemo(() => ({ hideAttribution }), [hideAttribution]);\n const showToolbar = !readOnly && tool === \"select\" && selectedNodes.length > 0 && !dragging && !editing.node && !connectMenu && !listView;\n const templates = templatesProp ?? starterTemplates();\n\n // Where the floating toolbars hang, in board coordinates (Floating turns\n // them into screen ones as the view moves).\n const selBounds = showToolbar ? boardBounds(selectedNodes) : null;\n const selBox = selBounds\n ? {\n x: selBounds.x + selBounds.width / 2,\n top: selBounds.y,\n bottom: selBounds.y + selBounds.height,\n // A frame's title sits above its box: clear it.\n lift: selectedNodes.some((n) => n.type === \"frame\") ? 34 : 14,\n }\n : null;\n const soleEdgeObj = !readOnly && tool === \"select\" && soleEdge && !editing.edge && !connectMenu && !listView ? selectedEdges[0] : null;\n const edgeBar = (() => {\n if (!soleEdgeObj) return null;\n const s = nodes.find((n) => n.id === soleEdgeObj.source);\n const t = nodes.find((n) => n.id === soleEdgeObj.target);\n if (!s || !t) return null;\n const ra = nodeRect(s);\n const rb = nodeRect(t);\n const sides = facingSides(ra, rb);\n const g = connectorGeometry(sidePoint(ra, sides.source), sides.source, sidePoint(rb, sides.target), sides.target, soleEdgeObj.data?.line ?? \"curved\");\n return { at: { x: g.mid.x, top: g.mid.y, bottom: g.mid.y, lift: 26 }, edge: soleEdgeObj };\n })();\n const isEmpty = nodes.length === 0;\n const board = listView ? cleanBoard({ nodes, edges }, { viewport: false }) : null;\n\n return (\n <CanvasContext.Provider value={ctx}>\n <div\n ref={wrapRef}\n role=\"region\"\n aria-label={label}\n aria-roledescription=\"board\"\n tabIndex={-1}\n data-tool={cursorTool}\n data-connecting={connecting ? \"true\" : \"false\"}\n className={cx(\"relative isolate overflow-hidden bg-background text-foreground outline-none\", !bare && \"rounded-lg border border-border\", THEME, className)}\n style={{ height }}\n onKeyDownCapture={onKeyDownCapture}\n onFocusCapture={onFocusCapture}\n onPointerDownCapture={onPointerDownCapture}\n onPointerMove={(e) => {\n if (e.pointerType === \"mouse\") pointer.current = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });\n }}\n onPointerLeave={() => {\n pointer.current = null;\n }}\n onDoubleClick={onDoubleClick}\n >\n <p className=\"sr-only\" aria-live=\"polite\">\n {announce}\n </p>\n <ContextMenu items={ctxItems} label={`${label} actions`} className=\"absolute inset-0\">\n <ReactFlow\n nodes={nodes}\n edges={edges}\n nodeTypes={nodeTypes}\n edgeTypes={BUILT_IN_EDGES}\n defaultEdgeOptions={EDGE_DEFAULTS}\n onNodesChange={onNodesChange}\n onEdgesChange={onEdgesChange}\n {...handlers}\n connectionMode={ConnectionMode.Loose}\n connectionRadius={28}\n snapToGrid={snap}\n snapGrid={snapGrid}\n panOnScroll\n zoomOnScroll={false}\n zoomOnPinch\n zoomOnDoubleClick={false}\n panOnDrag={tool === \"hand\" ? true : MIDDLE_BUTTON}\n selectionOnDrag={tool === \"select\" && !readOnly}\n selectionMode={SelectionMode.Full}\n multiSelectionKeyCode={MULTI_KEYS}\n deleteKeyCode={null}\n nodesDraggable={!readOnly && (tool === \"select\" || placing)}\n nodesConnectable={!readOnly}\n elementsSelectable={!readOnly && tool !== \"hand\"}\n elevateNodesOnSelect={false}\n elevateEdgesOnSelect\n minZoom={0.1}\n maxZoom={4}\n fitView={!viewportRef.current}\n fitViewOptions={FIT_OPTIONS}\n defaultViewport={viewportRef.current ?? undefined}\n proOptions={proOptions}\n attributionPosition={narrow ? \"top-left\" : \"bottom-center\"}\n colorMode=\"light\"\n aria-label={label}\n >\n <Background variant={BackgroundVariant.Dots} gap={grid} size={1.6} />\n {minimap && !narrow ? <MiniMap pannable zoomable ariaLabel=\"Minimap\" position=\"bottom-right\" nodeBorderRadius={6} className=\"bottom-14!\" /> : null}\n {draft ? (\n <ViewportPortal>\n <div\n aria-hidden=\"true\"\n className=\"absolute top-0 left-0 rounded-md border-2 border-dashed border-primary bg-primary/5\"\n style={{ transform: `translate(${draft.x}px, ${draft.y}px)`, width: draft.width, height: draft.height }}\n />\n </ViewportPortal>\n ) : null}\n </ReactFlow>\n </ContextMenu>\n\n {isEmpty && !readOnly && emptyState !== false && !listView && tool === \"select\" ? (\n emptyState ? (\n <div className=\"pointer-events-none absolute inset-0 grid place-items-center p-6\">\n <div className=\"fc-chrome pointer-events-auto\">{emptyState}</div>\n </div>\n ) : (\n <EmptyState\n templates={templates}\n narrow={narrow}\n onTemplate={(t) => {\n const b = typeof t.board === \"function\" ? t.board() : t.board;\n api.setBoard(b);\n say(`${t.label} template added`);\n }}\n />\n )\n ) : null}\n\n {showToolbar && selBox ? (\n <Floating at={selBox}>\n <SelectionToolbar nodes={selectedNodes} api={api} palette={palette} extra={toolbarExtra} />\n </Floating>\n ) : null}\n {edgeBar ? (\n <Floating at={edgeBar.at}>\n <EdgeToolbar id={edgeBar.edge.id} data={edgeBar.edge.data} label={edgeBar.edge.label} />\n </Floating>\n ) : null}\n\n {topBar ? <div className=\"pointer-events-none absolute inset-x-3 top-3 z-10 flex justify-center [&>*]:pointer-events-auto\">{topBar}</div> : null}\n\n {!readOnly ? (\n <div className={cx(\"pointer-events-none absolute z-10 flex gap-2\", narrow ? \"inset-x-3 bottom-3 flex-col-reverse items-center\" : \"inset-y-3 left-3 flex-row items-center\")}>\n <ToolRail items={railItems} tool={tool} onPick={pickTool} narrow={narrow} />\n <ToolFlyout tool={tool} palette={palette} stickyColor={stickyColor} onStickyColor={setStickyColor} shapeKind={shapeKind} onShapeKind={setShapeKind} />\n </div>\n ) : null}\n\n {!readOnly ? (\n <div className={cx(\"absolute left-3 z-10\", narrow ? (tool === \"sticky\" || tool === \"shape\" ? \"bottom-32\" : \"bottom-20\") : \"bottom-3\")}>\n <HistoryBar canUndo={history.past.length > 0} canRedo={history.future.length > 0} onUndo={undo} onRedo={redo} save={save} onRetry={() => runSave(currentBoard())} />\n </div>\n ) : null}\n\n <div className={cx(\"absolute right-3 z-10\", narrow ? (topBar ? \"top-16\" : \"top-3\") : \"bottom-3\")}>\n <ZoomBar\n zoom={<ZoomLevel />}\n narrow={narrow}\n presentation={presentation}\n onZoomIn={() => rf.zoomIn({ duration })}\n onZoomOut={() => rf.zoomOut({ duration })}\n onZoomTo={(z) => rf.zoomTo(z, { duration })}\n onFit={() => fitView()}\n onFitSelection={() => fitView({ nodes: selectedNodes.map((n) => ({ id: n.id })), maxZoom: 2 })}\n hasSelection={selectedNodes.length > 0}\n minimap={minimap}\n onMinimap={setMinimap}\n list={listView}\n onList={setListView}\n snap={snap}\n onSnap={setSnap}\n onShortcuts={() => setSheet(true)}\n />\n </div>\n\n {presentation ? (\n <div className=\"pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center\">\n <PresentBar\n index={slide}\n count={frames.length}\n title={frames[slide]?.data?.title || \"Untitled frame\"}\n onPrev={() => goSlide(slide - 1)}\n onNext={() => goSlide(slide + 1)}\n onExit={onExitPresentation}\n />\n </div>\n ) : null}\n\n {connectMenu ? <ConnectMenu at={connectMenu.at} items={connectItems} onClose={() => setConnectMenu(null)} /> : null}\n\n {listView ? (\n <ListView\n board={board}\n label={label}\n describe={describeNode}\n iconOf={(n) => toolById.get(n.type)?.icon}\n onClose={() => setListView(false)}\n onPick={(id) => {\n setListView(false);\n requestAnimationFrame(() => focusNode(id));\n }}\n />\n ) : null}\n\n <ShortcutsSheet open={sheet} onClose={() => setSheet(false)} tools={railItems} />\n </div>\n </CanvasContext.Provider>\n );\n}\n\n"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"path": "registry/blocks/flow-canvas/nodes.jsx",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"path": "registry/blocks/flow-canvas/board.js",
|
|
52
52
|
"type": "registry:component",
|
|
53
53
|
"target": "@components/makersclaw/blocks/flow-canvas/board.js",
|
|
54
|
-
"content": "// The board as data — plain functions, no React, no React Flow. They run\n// anywhere (the server, a script, an agent's tool) and never throw on a\n// partial board. FlowCanvas uses the same functions, so what an agent\n// reads with boardToText is what the person sees.\n//\n// A board is plain JSON an app stores in one jsonb column:\n// { nodes: [{ id, type, position: { x, y }, width?, height?, data, zIndex?,\n// draggable?, deletable?, selectable?, connectable? }],\n// edges: [{ id, source, target, label?, data?: { line, arrow, dashed } }],\n// viewport?: { x, y, zoom } }\n// Positions are absolute (a card in a frame is NOT stored relative to it):\n// a frame holds whatever lies inside its rectangle.\n\nexport const GRID = 20;\n\n/** Colours a person can pick, as TOKEN names — the board stores these, never\n * a colour value, so a board repaints with the workspace theme. */\nexport const COLORS = [\"warning\", \"chart-1\", \"chart-2\", \"chart-3\", \"chart-4\", \"chart-5\", \"success\", \"info\", \"destructive\", \"muted\"];\n/** The sticky palette the rail and the toolbar offer by default. */\nexport const STICKY_COLORS = [\"warning\", \"chart-2\", \"chart-3\", \"chart-4\", \"chart-5\", \"muted\"];\nexport const COLOR_NAMES = {\n warning: \"Yellow\",\n \"chart-1\": \"Theme colour\",\n \"chart-2\": \"Colour 2\",\n \"chart-3\": \"Colour 3\",\n \"chart-4\": \"Colour 4\",\n \"chart-5\": \"Colour 5\",\n success: \"Green\",\n info: \"Blue\",\n destructive: \"Red\",\n muted: \"Grey\",\n};\n// The chart tokens are derived from each workspace's primary colour, so\n// they get numbers, not hue names: \"Colour 3\" is violet in one theme and\n// orange in another. Labels only; the board stores the token.\n\nexport const SHAPES = [\"rect\", \"ellipse\", \"diamond\", \"pill\"];\nexport const SHAPE_NAMES = { rect: \"Rectangle\", ellipse: \"Ellipse\", diamond: \"Diamond\", pill: \"Pill\" };\nexport const TEXT_SIZES = [\"sm\", \"md\", \"lg\", \"xl\"];\nexport const TEXT_SIZE_PX = { sm: 14, md: 18, lg: 24, xl: 36 };\nexport const LINES = [\"curved\", \"straight\", \"elbow\"];\nexport const ARROWS = [\"end\", \"both\", \"none\"];\n\n/** Default size per built-in type. `height: undefined` grows with content. */\nexport const SIZES = {\n sticky: { width: 200, height: 200 },\n shape: { width: 160, height: 100 },\n text: { width: 240, height: undefined },\n frame: { width: 560, height: 360 },\n card: { width: 260, height: undefined },\n image: { width: 280, height: 180 },\n};\nconst SHAPE_SIZES = { rect: [160, 100], ellipse: [160, 110], diamond: [160, 120], pill: [180, 64] };\n/** Types whose height follows their content unless someone resized them. */\nexport const AUTO_HEIGHT = new Set([\"text\", \"card\"]);\n/** Types whose words edit in place on double-click / Enter. */\nexport const EDITABLE = new Set([\"sticky\", \"shape\", \"text\", \"frame\", \"card\"]);\n\nconst isObj = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v);\nconst num = (v, d = 0) => (typeof v === \"number\" && Number.isFinite(v) ? v : d);\n\nlet seq = 0;\n/** A fresh id: `sticky-lx3k9f2a`. Unique enough for a board. */\nexport function makeId(prefix = \"n\") {\n seq = (seq + 1) % 1296;\n return `${prefix}-${Date.now().toString(36)}${seq.toString(36).padStart(2, \"0\")}${Math.floor(Math.random() * 1296)\n .toString(36)\n .padStart(2, \"0\")}`;\n}\n\n/** An empty board. */\nexport function emptyBoard() {\n return { nodes: [], edges: [] };\n}\n\n/** Nodes and edges out of anything board-shaped; never throws. */\nexport function readBoard(board) {\n const nodes = Array.isArray(board?.nodes) ? board.nodes.filter((n) => isObj(n) && n.id != null) : [];\n const ids = new Set(nodes.map((n) => String(n.id)));\n const edges = Array.isArray(board?.edges)\n ? board.edges.filter((e) => isObj(e) && e.id != null && ids.has(String(e.source)) && ids.has(String(e.target)))\n : [];\n return { nodes, edges, viewport: isObj(board?.viewport) ? board.viewport : undefined };\n}\n\nconst NODE_KEYS = [\"id\", \"type\", \"position\", \"width\", \"height\", \"data\", \"zIndex\", \"draggable\", \"deletable\", \"selectable\", \"connectable\"];\nconst EDGE_KEYS = [\"id\", \"source\", \"target\", \"label\", \"data\"];\n\n/** The board as it is stored: React Flow's working fields (selected,\n * dragging, measured…) dropped, and nothing but JSON left. */\nexport function cleanBoard(board, { viewport = true } = {}) {\n const { nodes, edges, viewport: vp } = readBoard(board);\n const out = {\n nodes: nodes.map((n) => {\n const o = {};\n for (const k of NODE_KEYS) if (n[k] !== undefined && n[k] !== null) o[k] = n[k];\n o.position = { x: Math.round(num(n.position?.x) * 100) / 100, y: Math.round(num(n.position?.y) * 100) / 100 };\n if (typeof o.width === \"number\") o.width = Math.round(o.width);\n if (typeof o.height === \"number\") o.height = Math.round(o.height);\n o.data = isObj(n.data) ? n.data : {};\n return o;\n }),\n edges: edges.map((e) => {\n const o = {};\n for (const k of EDGE_KEYS) if (e[k] !== undefined && e[k] !== null && e[k] !== \"\") o[k] = e[k];\n return o;\n }),\n };\n if (viewport && vp) out.viewport = { x: num(vp.x), y: num(vp.y), zoom: num(vp.zoom, 1) };\n return out;\n}\n\n/** A string that changes when the board's content changes (not the view). */\nexport function boardKey(board) {\n const { nodes, edges } = cleanBoard(board, { viewport: false });\n return JSON.stringify([nodes, edges]);\n}\n\n// ── geometry ─────────────────────────────────────────────────────────────\n\n/** A node's size: set, measured by React Flow, or its type's default. */\nexport function nodeSize(node) {\n const d = SIZES[node?.type] ?? { width: 200, height: 120 };\n const width = num(node?.width, num(node?.measured?.width, d.width ?? 200));\n const height = num(node?.height, num(node?.measured?.height, d.height ?? (node?.type === \"text\" ? 40 : 120)));\n return { width, height };\n}\n\n/** { x, y, width, height } in board coordinates. */\nexport function nodeRect(node) {\n const { width, height } = nodeSize(node);\n return { x: num(node?.position?.x), y: num(node?.position?.y), width, height };\n}\n\nconst center = (r) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 });\nconst inside = (p, r) => p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;\n\n/** The rectangle around some nodes, or null. */\nexport function boardBounds(nodes) {\n const list = (nodes ?? []).filter(Boolean);\n if (!list.length) return null;\n let x1 = Infinity;\n let y1 = Infinity;\n let x2 = -Infinity;\n let y2 = -Infinity;\n for (const n of list) {\n const r = nodeRect(n);\n x1 = Math.min(x1, r.x);\n y1 = Math.min(y1, r.y);\n x2 = Math.max(x2, r.x + r.width);\n y2 = Math.max(y2, r.y + r.height);\n }\n return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };\n}\n\n/** The ids of the nodes inside a frame: their centre lies in its rectangle.\n * Frames nested in it count, and so does what they hold. */\nexport function frameContents(board, frameId) {\n const { nodes } = readBoard(board);\n const frame = nodes.find((n) => n.id === frameId);\n if (!frame) return [];\n const r = nodeRect(frame);\n return nodes.filter((n) => n.id !== frameId && inside(center(nodeRect(n)), r) && area(nodeRect(n)) < area(r)).map((n) => n.id);\n}\nconst area = (r) => r.width * r.height;\n\n/** The smallest frame each node sits in: Map(nodeId → frameId). */\nexport function frameOf(board) {\n const { nodes } = readBoard(board);\n const frames = nodes.filter((n) => n.type === \"frame\").map((f) => ({ id: f.id, r: nodeRect(f) }));\n const out = new Map();\n for (const n of nodes) {\n const c = center(nodeRect(n));\n const a = area(nodeRect(n));\n let best = null;\n for (const f of frames) {\n if (f.id === n.id || !inside(c, f.r) || area(f.r) <= a) continue;\n if (!best || area(f.r) < area(best.r)) best = f;\n }\n if (best) out.set(n.id, best.id);\n }\n return out;\n}\n\n/** Round a point onto the grid. */\nexport function snapPoint(p, grid = GRID) {\n if (!grid) return { x: p.x, y: p.y };\n return { x: Math.round(p.x / grid) * grid, y: Math.round(p.y / grid) * grid };\n}\n\n/**\n * Put a rectangle into a cell — for boards with lanes or a grid (a journey\n * map's stage × row). `cells` is [{ id, x, y, width, height }]. The cell\n * is the one holding the rectangle's centre, else the nearest; the\n * rectangle is kept inside it with `pad` to spare. Returns\n * { x, y, cellId } or null when there are no cells.\n */\nexport function snapToCell(rect, cells, { pad = 8 } = {}) {\n const list = (cells ?? []).filter((c) => c && num(c.width) > 0 && num(c.height) > 0);\n if (!list.length) return null;\n const c = center(rect);\n let cell = list.find((k) => inside(c, k));\n if (!cell) {\n let best = Infinity;\n for (const k of list) {\n const dx = Math.max(k.x - c.x, 0, c.x - (k.x + k.width));\n const dy = Math.max(k.y - c.y, 0, c.y - (k.y + k.height));\n const d = dx * dx + dy * dy;\n if (d < best) {\n best = d;\n cell = k;\n }\n }\n }\n const clamp = (v, lo, hi) => (hi < lo ? (lo + hi) / 2 : Math.min(Math.max(v, lo), hi));\n return {\n x: clamp(rect.x, cell.x + pad, cell.x + cell.width - pad - rect.width),\n y: clamp(rect.y, cell.y + pad, cell.y + cell.height - pad - rect.height),\n cellId: cell.id,\n };\n}\n\n/** Where a connector leaves and enters: the sides of two rectangles that\n * face each other. → { source: \"right\", target: \"left\" } */\nexport function facingSides(a, b) {\n const ca = center(a);\n const cb = center(b);\n const dx = cb.x - ca.x;\n const dy = cb.y - ca.y;\n const horizontal = Math.abs(dx) / Math.max(1, a.width + b.width) >= Math.abs(dy) / Math.max(1, a.height + b.height);\n if (horizontal) return dx >= 0 ? { source: \"right\", target: \"left\" } : { source: \"left\", target: \"right\" };\n return dy >= 0 ? { source: \"bottom\", target: \"top\" } : { source: \"top\", target: \"bottom\" };\n}\n\n/** The midpoint of one side of a rectangle. */\nexport function sidePoint(r, side) {\n if (side === \"left\") return { x: r.x, y: r.y + r.height / 2 };\n if (side === \"right\") return { x: r.x + r.width, y: r.y + r.height / 2 };\n if (side === \"top\") return { x: r.x + r.width / 2, y: r.y };\n return { x: r.x + r.width / 2, y: r.y + r.height };\n}\n\n/**\n * The biggest font size (px) at which `text` fits a box — a sticky's words\n * shrink as they grow. An estimate (no DOM): average glyph width `charWidth`\n * em, lines wrap at words.\n */\nexport function fitFontSize(text, width, height, { min = 12, max = 30, lineHeight = 1.25, charWidth = 0.56 } = {}) {\n const words = String(text ?? \"\").trim();\n if (!words) return max;\n const paras = words.split(/\\n/);\n const fits = (s) => {\n const perLine = Math.max(1, Math.floor(width / (s * charWidth)));\n let lines = 0;\n for (const p of paras) {\n let line = 0;\n let count = 1;\n for (const w of p.split(/\\s+/).filter(Boolean)) {\n if (w.length > perLine) {\n // A long word breaks across lines.\n const extra = Math.ceil(w.length / perLine);\n count += line ? extra : extra - 1;\n line = w.length % perLine;\n continue;\n }\n const need = line ? line + 1 + w.length : w.length;\n if (need > perLine) {\n count += 1;\n line = w.length;\n } else line = need;\n }\n lines += count;\n }\n return lines * s * lineHeight <= height;\n };\n let lo = min;\n let hi = max;\n if (fits(hi)) return hi;\n if (!fits(lo)) return lo;\n while (hi - lo > 0.5) {\n const mid = (lo + hi) / 2;\n if (fits(mid)) lo = mid;\n else hi = mid;\n }\n return Math.floor(lo);\n}\n\n// ── making and changing nodes ────────────────────────────────────────────\n\nconst DATA = {\n sticky: () => ({ text: \"\", color: \"warning\" }),\n shape: () => ({ text: \"\", shape: \"rect\", color: \"muted\", textSize: \"md\" }),\n text: () => ({ text: \"\", textSize: \"md\" }),\n frame: () => ({ title: \"Frame\" }),\n card: () => ({ title: \"\", body: \"\" }),\n image: () => ({ src: \"\", alt: \"\" }),\n};\n\n/**\n * A new node CENTRED on `point`, snapped to the grid, with its type's\n * defaults (size, data). Custom types take `width`/`height` from opts\n * (default 200×120).\n *\n * createNode(\"sticky\", { x: 400, y: 200 }, { data: { text: \"Idea\", color: \"chart-3\" } })\n * createNode(\"person\", p, { width: 240, data: { name: \"New person\" } })\n *\n * With `rect` ({ x, y, width, height }) it fills that rectangle instead\n * (a shape dragged out with the tool).\n */\nexport function createNode(type, point, { id, data, width, height, rect, grid = GRID, ...rest } = {}) {\n const base = SIZES[type] ?? { width: 200, height: 120 };\n const kind = type === \"shape\" ? data?.shape ?? \"rect\" : null;\n const [sw, sh] = kind ? SHAPE_SIZES[kind] ?? SHAPE_SIZES.rect : [];\n let w = num(rect?.width, num(width, sw ?? base.width));\n let h = rect ? num(rect.height) : height !== undefined ? height : sh ?? base.height;\n if (AUTO_HEIGHT.has(type) && !rect && height === undefined) h = undefined;\n const at = rect ? { x: rect.x, y: rect.y } : { x: num(point?.x) - w / 2, y: num(point?.y) - num(h, 40) / 2 };\n const node = {\n id: id ?? makeId(type),\n type,\n position: snapPoint(at, grid),\n data: { ...(DATA[type]?.() ?? {}), ...(data ?? {}) },\n ...rest,\n };\n if (w !== undefined) node.width = grid ? Math.max(grid, Math.round(w / grid) * grid) : w;\n if (h !== undefined) node.height = grid ? Math.max(grid, Math.round(h / grid) * grid) : h;\n if (type === \"frame\" && node.zIndex === undefined) node.zIndex = -1;\n return node;\n}\n\n/** A connector. `data`: { line: curved|straight|elbow, arrow: end|both|none, dashed }. */\nexport function createEdge(source, target, { id, label, data } = {}) {\n const e = { id: id ?? makeId(\"e\"), source, target, type: \"flow\", data: { line: \"curved\", arrow: \"end\", ...(data ?? {}) } };\n if (label) e.label = label;\n return e;\n}\n\n/**\n * Copies of some nodes (and the connectors between them), moved by\n * `offset`, with new ids. Frames bring what they hold. Returns\n * { nodes, edges } — only the new ones.\n */\nexport function duplicateNodes(board, ids, { offset = { x: GRID * 2, y: GRID * 2 } } = {}) {\n const { nodes, edges } = readBoard(board);\n const want = new Set(ids);\n for (const id of ids) {\n const n = nodes.find((x) => x.id === id);\n if (n?.type === \"frame\") for (const c of frameContents(board, id)) want.add(c);\n }\n const map = new Map();\n const out = [];\n for (const n of nodes) {\n if (!want.has(n.id)) continue;\n const id = makeId(n.type || \"n\");\n map.set(n.id, id);\n const { selected, dragging, measured, ...rest } = n;\n const copy = { ...rest, id, position: { x: num(n.position?.x) + offset.x, y: num(n.position?.y) + offset.y }, data: structuredCopy(n.data) };\n if (copy.data?.locked) {\n delete copy.data.locked;\n delete copy.draggable;\n delete copy.deletable;\n }\n out.push(copy);\n }\n const outEdges = edges\n .filter((e) => map.has(e.source) && map.has(e.target))\n .map((e) => ({ ...e, id: makeId(\"e\"), source: map.get(e.source), target: map.get(e.target), selected: undefined }));\n return { nodes: out, edges: outEdges };\n}\n\nfunction structuredCopy(v) {\n try {\n return JSON.parse(JSON.stringify(v ?? {}));\n } catch {\n return { ...(v ?? {}) };\n }\n}\n\n/** The board without some nodes and every connector that touched them. */\nexport function removeNodes(board, ids) {\n const { nodes, edges } = readBoard(board);\n const gone = new Set(ids);\n return { ...board, nodes: nodes.filter((n) => !gone.has(n.id)), edges: edges.filter((e) => !gone.has(e.source) && !gone.has(e.target)) };\n}\n\n/**\n * Line up nodes: how = left | center | right | top | middle | bottom.\n * Returns the whole node list with those moved.\n */\nexport function alignNodes(nodes, ids, how) {\n const set = new Set(ids);\n const pick = (nodes ?? []).filter((n) => set.has(n.id));\n const b = boardBounds(pick);\n if (!b || pick.length < 2) return nodes;\n return nodes.map((n) => {\n if (!set.has(n.id) || n.data?.locked) return n;\n const r = nodeRect(n);\n const p = { ...n.position };\n if (how === \"left\") p.x = b.x;\n else if (how === \"center\") p.x = b.x + b.width / 2 - r.width / 2;\n else if (how === \"right\") p.x = b.x + b.width - r.width;\n else if (how === \"top\") p.y = b.y;\n else if (how === \"middle\") p.y = b.y + b.height / 2 - r.height / 2;\n else if (how === \"bottom\") p.y = b.y + b.height - r.height;\n return { ...n, position: p };\n });\n}\n\n/** Even gaps between nodes along an axis: \"horizontal\" | \"vertical\". */\nexport function distributeNodes(nodes, ids, axis = \"horizontal\") {\n const set = new Set(ids);\n const h = axis === \"horizontal\";\n const pick = (nodes ?? []).filter((n) => set.has(n.id)).map((n) => ({ n, r: nodeRect(n) }));\n if (pick.length < 3) return nodes;\n pick.sort((a, b) => (h ? a.r.x - b.r.x : a.r.y - b.r.y));\n const first = pick[0].r;\n const last = pick[pick.length - 1].r;\n const span = h ? last.x + last.width - first.x : last.y + last.height - first.y;\n const used = pick.reduce((s, p) => s + (h ? p.r.width : p.r.height), 0);\n const gap = (span - used) / (pick.length - 1);\n const at = new Map();\n let cursor = h ? first.x : first.y;\n for (const p of pick) {\n at.set(p.n.id, cursor);\n cursor += (h ? p.r.width : p.r.height) + gap;\n }\n return nodes.map((n) => (at.has(n.id) && !n.data?.locked ? { ...n, position: h ? { ...n.position, x: at.get(n.id) } : { ...n.position, y: at.get(n.id) } } : n));\n}\n\n/** Arrange: move nodes to the end (front) or start (back) of the list.\n * Paint order is list order; frames always sit under everything. */\nexport function arrangeNodes(nodes, ids, where = \"front\") {\n const set = new Set(ids);\n const moved = nodes.filter((n) => set.has(n.id));\n const rest = nodes.filter((n) => !set.has(n.id));\n return where === \"back\" ? [...moved, ...rest] : [...rest, ...moved];\n}\n\n// ── layout ───────────────────────────────────────────────────────────────\n\n/**\n * A layered layout (sources first, each step one rank further) with no\n * dependency: cycles are broken, ranks are longest paths, and a few\n * barycentre sweeps untangle crossings. Nodes nobody connects sit in a row\n * after the rest. The result keeps the top-left corner the nodes had, so\n * nothing flies off screen.\n *\n * autoLayout(nodes, edges, { direction: \"LR\" | \"TB\", rankGap: 80, nodeGap: 40, ids })\n *\n * `ids` lays out only those nodes (default: every node but frames).\n * Returns the whole node list with new positions.\n */\nexport function autoLayout(nodes, edges, { direction = \"LR\", rankGap = 80, nodeGap = 40, ids, grid = GRID } = {}) {\n const all = nodes ?? [];\n const pickIds = new Set(ids ?? all.filter((n) => n.type !== \"frame\").map((n) => n.id));\n const pick = all.filter((n) => pickIds.has(n.id));\n if (!pick.length) return all;\n const before = boardBounds(pick);\n const lr = direction !== \"TB\";\n const size = new Map(pick.map((n) => [n.id, nodeSize(n)]));\n const main = (id) => (lr ? size.get(id).width : size.get(id).height);\n const cross = (id) => (lr ? size.get(id).height : size.get(id).width);\n\n const order = pick.map((n) => n.id);\n const index = new Map(order.map((id, i) => [id, i]));\n const links = (edges ?? []).filter((e) => pickIds.has(e.source) && pickIds.has(e.target) && e.source !== e.target);\n\n // Break cycles: a DFS in list order; an edge back into the stack is reversed.\n const out = new Map(order.map((id) => [id, []]));\n for (const e of links) out.get(e.source).push(e.target);\n const state = new Map();\n const dag = [];\n const visit = (u) => {\n state.set(u, 1);\n for (const v of out.get(u)) {\n if (state.get(v) === 1) dag.push([v, u]);\n else {\n dag.push([u, v]);\n if (!state.get(v)) visit(v);\n }\n }\n state.set(u, 2);\n };\n for (const id of order) if (!state.get(id)) visit(id);\n\n const preds = new Map(order.map((id) => [id, []]));\n const succs = new Map(order.map((id) => [id, []]));\n const seen = new Set();\n for (const [u, v] of dag) {\n const k = `${u}>${v}`;\n if (seen.has(k)) continue;\n seen.add(k);\n succs.get(u).push(v);\n preds.get(v).push(u);\n }\n\n // Components: connected groups, laid side by side on the cross axis.\n const comp = new Map();\n let compCount = 0;\n for (const id of order) {\n if (comp.has(id)) continue;\n const stack = [id];\n comp.set(id, compCount);\n while (stack.length) {\n const u = stack.pop();\n for (const v of [...succs.get(u), ...preds.get(u)]) if (!comp.has(v)) (comp.set(v, compCount), stack.push(v));\n }\n compCount++;\n }\n\n // Ranks: longest path from a source (Kahn order).\n const rank = new Map();\n const indeg = new Map(order.map((id) => [id, preds.get(id).length]));\n const queue = order.filter((id) => indeg.get(id) === 0);\n for (const id of queue) rank.set(id, 0);\n for (let i = 0; i < queue.length; i++) {\n const u = queue[i];\n for (const v of succs.get(u)) {\n rank.set(v, Math.max(rank.get(v) ?? 0, rank.get(u) + 1));\n indeg.set(v, indeg.get(v) - 1);\n if (indeg.get(v) === 0) queue.push(v);\n }\n }\n\n const loose = order.filter((id) => !preds.get(id).length && !succs.get(id).length);\n const looseSet = new Set(loose);\n const groups = [];\n for (let c = 0; c < compCount; c++) {\n const members = order.filter((id) => comp.get(id) === c && !looseSet.has(id));\n if (members.length) groups.push(members);\n }\n\n const positions = new Map();\n let crossCursor = 0;\n for (const members of groups) {\n const maxRank = Math.max(...members.map((id) => rank.get(id)));\n const layers = Array.from({ length: maxRank + 1 }, () => []);\n for (const id of members) layers[rank.get(id)].push(id);\n for (const l of layers) l.sort((a, b) => index.get(a) - index.get(b));\n // Barycentre sweeps: order each layer by the mean slot of its neighbours.\n const slot = new Map();\n const reslot = () => layers.forEach((l) => l.forEach((id, i) => slot.set(id, i)));\n reslot();\n const bary = (id, nbrs) => {\n const s = nbrs.filter((x) => slot.has(x));\n return s.length ? s.reduce((t, x) => t + slot.get(x), 0) / s.length : slot.get(id);\n };\n for (let pass = 0; pass < 4; pass++) {\n for (let r = 1; r < layers.length; r++) {\n layers[r].sort((a, b) => bary(a, preds.get(a)) - bary(b, preds.get(b)) || index.get(a) - index.get(b));\n reslot();\n }\n for (let r = layers.length - 2; r >= 0; r--) {\n layers[r].sort((a, b) => bary(a, succs.get(a)) - bary(b, succs.get(b)) || index.get(a) - index.get(b));\n reslot();\n }\n }\n // Place: ranks along the main axis, each layer centred on the widest.\n const extent = layers.map((l) => l.reduce((s, id) => s + cross(id), 0) + nodeGap * Math.max(0, l.length - 1));\n const widest = Math.max(...extent);\n let mainCursor = 0;\n layers.forEach((l, r) => {\n const depth = Math.max(...l.map(main));\n let c = crossCursor + (widest - extent[r]) / 2;\n for (const id of l) {\n const m = mainCursor + (depth - main(id)) / 2;\n positions.set(id, lr ? { x: m, y: c } : { x: c, y: m });\n c += cross(id) + nodeGap;\n }\n mainCursor += depth + rankGap;\n });\n crossCursor += widest + rankGap;\n }\n // The unconnected ones: one row (or column) after the groups.\n if (loose.length) {\n let m = 0;\n const depth = Math.max(...loose.map(cross));\n for (const id of loose) {\n positions.set(id, lr ? { x: m, y: crossCursor + (depth - cross(id)) / 2 } : { x: crossCursor + (depth - cross(id)) / 2, y: m });\n m += main(id) + nodeGap;\n }\n }\n\n // Keep the old top-left corner.\n const laid = pick.map((n) => ({ ...n, position: positions.get(n.id) }));\n const after = boardBounds(laid);\n const dx = before.x - after.x;\n const dy = before.y - after.y;\n const moved = new Map(laid.map((n) => [n.id, snapPoint({ x: n.position.x + dx, y: n.position.y + dy }, grid)]));\n return all.map((n) => (moved.has(n.id) && !n.data?.locked ? { ...n, position: moved.get(n.id) } : n));\n}\n\n// ── words out ────────────────────────────────────────────────────────────\n\n/** The words a node shows: text, title, label or name. */\nexport function nodeText(node) {\n const d = node?.data ?? {};\n for (const k of [\"text\", \"title\", \"label\", \"name\"]) if (typeof d[k] === \"string\" && d[k].trim()) return d[k].trim();\n if (node?.type === \"image\") return String(d.alt || d.caption || \"\").trim();\n return \"\";\n}\n\nconst KIND = { sticky: \"Sticky\", shape: \"Shape\", text: \"Text\", frame: \"Frame\", card: \"Card\", image: \"Image\" };\nconst quote = (s) => `\"${String(s).replace(/\\s+/g, \" \").trim()}\"`;\nconst readingOrder = (a, b) => {\n const ra = nodeRect(a);\n const rb = nodeRect(b);\n return Math.abs(ra.y - rb.y) > 24 ? ra.y - rb.y : ra.x - rb.x;\n};\n\n/** One line for a node: `Sticky (yellow): \"Ship the beta\"`. */\nexport function describeNode(node, describe) {\n const own = describe?.(node);\n if (typeof own === \"string\" && own) return own;\n const d = node?.data ?? {};\n const text = nodeText(node);\n if (node?.type === \"shape\") return `${SHAPE_NAMES[d.shape] ?? \"Shape\"}: ${text ? quote(text) : \"(empty)\"}`;\n if (node?.type === \"sticky\") {\n const extra = [d.badge != null && d.badge !== \"\" ? `${d.badge}` : null, d.meta || null].filter(Boolean).join(\", \");\n return `Sticky: ${text ? quote(text) : \"(empty)\"}${extra ? ` [${extra}]` : \"\"}`;\n }\n if (node?.type === \"card\") return `Card${d.kind ? ` (${d.kind})` : \"\"}: ${text ? quote(text) : \"(untitled)\"}${d.body ? ` — ${String(d.body).replace(/\\s+/g, \" \").trim()}` : \"\"}`;\n if (node?.type === \"image\") return `Image: ${text ? quote(text) : \"(no description)\"}`;\n const kind = KIND[node?.type] ?? String(node?.type ?? \"Item\").replace(/^\\w/, (c) => c.toUpperCase());\n return `${kind}: ${text ? quote(text) : \"(empty)\"}`;\n}\n\nfunction outline(board) {\n const { nodes, edges } = readBoard(board);\n const parent = frameOf(board);\n const kids = new Map();\n for (const n of nodes) {\n const p = parent.get(n.id) ?? null;\n if (!kids.has(p)) kids.set(p, []);\n kids.get(p).push(n);\n }\n for (const list of kids.values()) list.sort((a, b) => (a.type === \"frame\") - (b.type === \"frame\") || readingOrder(a, b));\n const byId = new Map(nodes.map((n) => [n.id, n]));\n return { nodes, edges, kids, byId };\n}\n\nconst edgeLine = (e, byId) => {\n const a = nodeText(byId.get(e.source)) || byId.get(e.source)?.type || e.source;\n const b = nodeText(byId.get(e.target)) || byId.get(e.target)?.type || e.target;\n const arrow = e.data?.arrow === \"both\" ? \"↔\" : e.data?.arrow === \"none\" ? \"—\" : \"→\";\n return `${quote(a)} ${arrow} ${quote(b)}${e.label ? ` (${String(e.label).trim()})` : \"\"}`;\n};\n\n/**\n * The board as plain text, for an agent or a screen reader: frames with\n * what they hold (in reading order, nested), loose items, connectors.\n * `describe(node)` returns a line for a custom node type (or nothing, for\n * the default).\n */\nexport function boardToText(board, { describe } = {}) {\n const { nodes, edges, kids, byId } = outline(board);\n if (!nodes.length) return \"Empty board.\";\n const lines = [`Board: ${nodes.length} item${nodes.length === 1 ? \"\" : \"s\"}, ${edges.length} connector${edges.length === 1 ? \"\" : \"s\"}.`];\n const walk = (parentId, depth) => {\n for (const n of kids.get(parentId) ?? []) {\n const pad = \" \".repeat(depth);\n if (n.type === \"frame\") {\n const count = (kids.get(n.id) ?? []).length;\n lines.push(`${pad}Frame ${quote(n.data?.title || \"Untitled\")} (${count} item${count === 1 ? \"\" : \"s\"}):`);\n walk(n.id, depth + 1);\n } else lines.push(`${pad}- ${describeNode(n, describe)}`);\n }\n };\n walk(null, 0);\n if (edges.length) {\n lines.push(\"Connectors:\");\n for (const e of edges) lines.push(`- ${edgeLine(e, byId)}`);\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * The board as GitHub markdown: `# title`, a heading per frame (nested\n * frames one level down), a bullet per item, a Connections list.\n */\nexport function boardToMarkdown(board, { title, describe } = {}) {\n const { nodes, edges, kids, byId } = outline(board);\n const out = [];\n if (title) out.push(`# ${title}`, \"\");\n if (!nodes.length) return [...out, \"_Empty board._\"].join(\"\\n\");\n const esc = (s) => String(s).replace(/([*_`[\\]])/g, \"\\\\$1\").replace(/\\s+/g, \" \").trim();\n const bullet = (n) => {\n const own = describe?.(n);\n if (typeof own === \"string\" && own) return `- ${own}`;\n const d = n.data ?? {};\n const text = nodeText(n);\n if (n.type === \"text\") return `- ${esc(text) || \"_(empty)_\"}`;\n if (n.type === \"shape\") return `- **${esc(text) || \"(empty)\"}** _(${(SHAPE_NAMES[d.shape] ?? \"shape\").toLowerCase()})_`;\n if (n.type === \"card\") return `- **${esc(text) || \"(untitled)\"}**${d.body ? ` — ${esc(d.body)}` : \"\"}`;\n if (n.type === \"image\") return d.src ? `- ` : `- _image: ${esc(text)}_`;\n const extra = n.type === \"sticky\" && d.badge != null && d.badge !== \"\" ? ` (${d.badge})` : \"\";\n return `- ${esc(text) || \"_(empty)_\"}${extra}`;\n };\n const loose = (kids.get(null) ?? []).filter((n) => n.type !== \"frame\");\n for (const n of loose) out.push(bullet(n));\n if (loose.length) out.push(\"\");\n const walk = (parentId, level) => {\n for (const f of (kids.get(parentId) ?? []).filter((n) => n.type === \"frame\")) {\n out.push(`${\"#\".repeat(Math.min(6, level))} ${esc(f.data?.title || \"Untitled\")}`, \"\");\n const items = (kids.get(f.id) ?? []).filter((n) => n.type !== \"frame\");\n for (const n of items) out.push(bullet(n));\n if (items.length) out.push(\"\");\n walk(f.id, level + 1);\n }\n };\n walk(null, title ? 2 : 1);\n if (edges.length) {\n out.push(`${\"#\".repeat(title ? 2 : 1)} Connections`, \"\");\n for (const e of edges) out.push(`- ${edgeLine(e, byId)}`);\n out.push(\"\");\n }\n return out.join(\"\\n\").trimEnd() + \"\\n\";\n}\n\n// ── history ──────────────────────────────────────────────────────────────\n\n/** Undo/redo as a reducer over board snapshots (use cleanBoard ones).\n * const [h, dispatch] = useReducer(historyReducer, board, historyInit)\n * dispatch({ type: \"push\", present }) a committed change (a no-op when equal)\n * dispatch({ type: \"undo\" }) · dispatch({ type: \"redo\" })\n * dispatch({ type: \"reset\", present }) a new document; no undo past it\n * h.present is the board to show; h.past / h.future the stacks. */\nexport function historyInit(present) {\n const p = cleanBoard(present ?? emptyBoard(), { viewport: false });\n return { past: [], present: p, future: [], key: boardKey(p) };\n}\n\nexport function historyReducer(h, action) {\n const limit = action?.limit ?? 100;\n switch (action?.type) {\n case \"push\": {\n const present = cleanBoard(action.present, { viewport: false });\n const key = boardKey(present);\n if (key === h.key) return h;\n return { past: [...h.past, h.present].slice(-limit), present, future: [], key };\n }\n case \"undo\": {\n if (!h.past.length) return h;\n const present = h.past[h.past.length - 1];\n return { past: h.past.slice(0, -1), present, future: [h.present, ...h.future].slice(0, limit), key: boardKey(present) };\n }\n case \"redo\": {\n if (!h.future.length) return h;\n const [present, ...future] = h.future;\n return { past: [...h.past, h.present].slice(-limit), present, future, key: boardKey(present) };\n }\n case \"reset\":\n return historyInit(action.present);\n default:\n return h;\n }\n}\n\n// ── starters ─────────────────────────────────────────────────────────────\n\n/** The empty-state starters FlowCanvas offers by default. Each `board()`\n * makes fresh ids. Pass your own list in `templates`, or [] for none. */\nexport function starterTemplates() {\n return [\n {\n id: \"brainstorm\",\n label: \"Brainstorm\",\n description: \"A frame and a few stickies to start from\",\n icon: \"lightbulb\",\n board: () => {\n const f = createNode(\"frame\", null, { rect: { x: 0, y: 0, width: 720, height: 420 }, data: { title: \"Ideas\" } });\n const s = (x, y, color, text) => createNode(\"sticky\", { x, y }, { data: { color, text } });\n return {\n nodes: [\n f,\n s(140, 160, \"warning\", \"What would make this a yes?\"),\n s(360, 160, \"chart-2\", \"\"),\n s(580, 160, \"chart-3\", \"\"),\n createNode(\"text\", { x: 360, y: 360 }, { width: 480, data: { text: \"Add one idea per sticky. Cluster them later.\", textSize: \"sm\" } }),\n ],\n edges: [],\n };\n },\n },\n {\n id: \"flowchart\",\n label: \"Flowchart\",\n description: \"Start, steps, a decision, connected\",\n icon: \"git-branch\",\n board: () => {\n const sh = (x, y, shape, text, color) => createNode(\"shape\", { x, y }, { data: { shape, text, color } });\n const a = sh(100, 200, \"pill\", \"Start\", \"chart-2\");\n const b = sh(340, 200, \"rect\", \"Do the thing\", \"muted\");\n const c = sh(580, 200, \"diamond\", \"Worked?\", \"warning\");\n const d = sh(820, 200, \"pill\", \"Done\", \"success\");\n return {\n nodes: [a, b, c, d],\n edges: [createEdge(a.id, b.id), createEdge(b.id, c.id), createEdge(c.id, d.id, { label: \"Yes\" }), createEdge(c.id, b.id, { label: \"No\", data: { line: \"elbow\" } })],\n };\n },\n },\n {\n id: \"retro\",\n label: \"Retro\",\n description: \"Went well, to improve, actions\",\n icon: \"history\",\n board: () => {\n const col = (i, title, color) => {\n const x = i * 340;\n return [\n createNode(\"frame\", null, { rect: { x, y: 0, width: 320, height: 460 }, data: { title } }),\n createNode(\"sticky\", { x: x + 160, y: 140 }, { width: 180, height: 180, data: { color, text: \"\" } }),\n ];\n };\n return { nodes: [...col(0, \"Went well\", \"success\"), ...col(1, \"To improve\", \"warning\"), ...col(2, \"Actions\", \"chart-5\")], edges: [] };\n },\n },\n ];\n}\n"
|
|
54
|
+
"content": "// The board as data — plain functions, no React, no React Flow. They run\n// anywhere (the server, a script, an agent's tool) and never throw on a\n// partial board. FlowCanvas uses the same functions, so what an agent\n// reads with boardToText is what the person sees.\n//\n// A board is plain JSON an app stores in one jsonb column:\n// { nodes: [{ id, type, position: { x, y }, width?, height?, data, zIndex?,\n// draggable?, deletable?, selectable?, connectable? }],\n// edges: [{ id, source, target, label?, data?: { line, arrow, dashed } }],\n// viewport?: { x, y, zoom } }\n// Positions are absolute (a card in a frame is NOT stored relative to it):\n// a frame holds whatever lies inside its rectangle.\n\nexport const GRID = 20;\n\n/** Colours a person can pick, as TOKEN names — the board stores these, never\n * a colour value, so a board repaints with the workspace theme. */\nexport const COLORS = [\"warning\", \"chart-1\", \"chart-2\", \"chart-3\", \"chart-4\", \"chart-5\", \"success\", \"info\", \"destructive\", \"muted\"];\n/** The sticky palette the rail and the toolbar offer by default. */\nexport const STICKY_COLORS = [\"warning\", \"chart-2\", \"chart-3\", \"chart-4\", \"chart-5\", \"muted\"];\nexport const COLOR_NAMES = {\n warning: \"Yellow\",\n \"chart-1\": \"Theme colour\",\n \"chart-2\": \"Colour 2\",\n \"chart-3\": \"Colour 3\",\n \"chart-4\": \"Colour 4\",\n \"chart-5\": \"Colour 5\",\n success: \"Green\",\n info: \"Blue\",\n destructive: \"Red\",\n muted: \"Grey\",\n};\n// The chart tokens are derived from each workspace's primary colour, so\n// they get numbers, not hue names: \"Colour 3\" is violet in one theme and\n// orange in another. Labels only; the board stores the token.\n\nexport const SHAPES = [\"rect\", \"ellipse\", \"diamond\", \"pill\"];\nexport const SHAPE_NAMES = { rect: \"Rectangle\", ellipse: \"Ellipse\", diamond: \"Diamond\", pill: \"Pill\" };\nexport const TEXT_SIZES = [\"sm\", \"md\", \"lg\", \"xl\"];\nexport const TEXT_SIZE_PX = { sm: 14, md: 18, lg: 24, xl: 36 };\nexport const LINES = [\"curved\", \"straight\", \"elbow\"];\nexport const ARROWS = [\"end\", \"both\", \"none\"];\n\n/** Default size per built-in type. `height: undefined` grows with content. */\nexport const SIZES = {\n sticky: { width: 200, height: 200 },\n shape: { width: 160, height: 100 },\n text: { width: 240, height: undefined },\n frame: { width: 560, height: 360 },\n card: { width: 260, height: undefined },\n image: { width: 280, height: 180 },\n};\nconst SHAPE_SIZES = { rect: [160, 100], ellipse: [160, 110], diamond: [160, 120], pill: [180, 64] };\n/** Types whose height follows their content unless someone resized them. */\nexport const AUTO_HEIGHT = new Set([\"text\", \"card\"]);\n/** Types that fit their words to their box (fitFontSize in nodes.jsx), so\n * they must always HAVE a box. React Flow hands a node its measured size\n * before its set one; a sticky or shape with no size is as big as its\n * words, and fitting the words to that size feeds back into itself — the\n * text size sets the box, the box sets the text size, and it flips every\n * frame (seen 2026-09-24: an app's seeded stickies alternating 12px and\n * 8px, 4,000 DOM writes a second, connectors jumping sides). */\nexport const FITTED = new Set([\"sticky\", \"shape\"]);\n\n/** A fitted node with each missing side filled from its type's default\n * (a shape's from its outline); a size that is set is kept. Any other\n * node comes back as it is. */\nexport function withFittedSize(node) {\n if (!FITTED.has(node?.type)) return node;\n const has = (v) => typeof v === \"number\" && Number.isFinite(v) && v > 0;\n if (has(node.width) && has(node.height)) return node;\n const [w, h] =\n node.type === \"shape\"\n ? SHAPE_SIZES[node.data?.shape] ?? [SIZES.shape.width, SIZES.shape.height]\n : [SIZES.sticky.width, SIZES.sticky.height];\n return { ...node, width: has(node.width) ? node.width : w, height: has(node.height) ? node.height : h };\n}\n/** Types whose words edit in place on double-click / Enter. */\nexport const EDITABLE = new Set([\"sticky\", \"shape\", \"text\", \"frame\", \"card\"]);\n\nconst isObj = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v);\nconst num = (v, d = 0) => (typeof v === \"number\" && Number.isFinite(v) ? v : d);\n\nlet seq = 0;\n/** A fresh id: `sticky-lx3k9f2a`. Unique enough for a board. */\nexport function makeId(prefix = \"n\") {\n seq = (seq + 1) % 1296;\n return `${prefix}-${Date.now().toString(36)}${seq.toString(36).padStart(2, \"0\")}${Math.floor(Math.random() * 1296)\n .toString(36)\n .padStart(2, \"0\")}`;\n}\n\n/** An empty board. */\nexport function emptyBoard() {\n return { nodes: [], edges: [] };\n}\n\n/** Nodes and edges out of anything board-shaped; never throws. */\nexport function readBoard(board) {\n const nodes = Array.isArray(board?.nodes) ? board.nodes.filter((n) => isObj(n) && n.id != null) : [];\n const ids = new Set(nodes.map((n) => String(n.id)));\n const edges = Array.isArray(board?.edges)\n ? board.edges.filter((e) => isObj(e) && e.id != null && ids.has(String(e.source)) && ids.has(String(e.target)))\n : [];\n return { nodes, edges, viewport: isObj(board?.viewport) ? board.viewport : undefined };\n}\n\nconst NODE_KEYS = [\"id\", \"type\", \"position\", \"width\", \"height\", \"data\", \"zIndex\", \"draggable\", \"deletable\", \"selectable\", \"connectable\"];\nconst EDGE_KEYS = [\"id\", \"source\", \"target\", \"label\", \"data\"];\n\n/** The board as it is stored: React Flow's working fields (selected,\n * dragging, measured…) dropped, and nothing but JSON left. */\nexport function cleanBoard(board, { viewport = true } = {}) {\n const { nodes, edges, viewport: vp } = readBoard(board);\n const out = {\n nodes: nodes.map((n) => {\n const o = {};\n for (const k of NODE_KEYS) if (n[k] !== undefined && n[k] !== null) o[k] = n[k];\n o.position = { x: Math.round(num(n.position?.x) * 100) / 100, y: Math.round(num(n.position?.y) * 100) / 100 };\n if (typeof o.width === \"number\") o.width = Math.round(o.width);\n if (typeof o.height === \"number\") o.height = Math.round(o.height);\n o.data = isObj(n.data) ? n.data : {};\n return o;\n }),\n edges: edges.map((e) => {\n const o = {};\n for (const k of EDGE_KEYS) if (e[k] !== undefined && e[k] !== null && e[k] !== \"\") o[k] = e[k];\n return o;\n }),\n };\n if (viewport && vp) out.viewport = { x: num(vp.x), y: num(vp.y), zoom: num(vp.zoom, 1) };\n return out;\n}\n\n/** A string that changes when the board's content changes (not the view). */\nexport function boardKey(board) {\n const { nodes, edges } = cleanBoard(board, { viewport: false });\n return JSON.stringify([nodes, edges]);\n}\n\n// ── geometry ─────────────────────────────────────────────────────────────\n\n/** A node's size: set, measured by React Flow, or its type's default. */\nexport function nodeSize(node) {\n const d = SIZES[node?.type] ?? { width: 200, height: 120 };\n const width = num(node?.width, num(node?.measured?.width, d.width ?? 200));\n const height = num(node?.height, num(node?.measured?.height, d.height ?? (node?.type === \"text\" ? 40 : 120)));\n return { width, height };\n}\n\n/** { x, y, width, height } in board coordinates. */\nexport function nodeRect(node) {\n const { width, height } = nodeSize(node);\n return { x: num(node?.position?.x), y: num(node?.position?.y), width, height };\n}\n\nconst center = (r) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 });\nconst inside = (p, r) => p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;\n\n/** The rectangle around some nodes, or null. */\nexport function boardBounds(nodes) {\n const list = (nodes ?? []).filter(Boolean);\n if (!list.length) return null;\n let x1 = Infinity;\n let y1 = Infinity;\n let x2 = -Infinity;\n let y2 = -Infinity;\n for (const n of list) {\n const r = nodeRect(n);\n x1 = Math.min(x1, r.x);\n y1 = Math.min(y1, r.y);\n x2 = Math.max(x2, r.x + r.width);\n y2 = Math.max(y2, r.y + r.height);\n }\n return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };\n}\n\n/** The ids of the nodes inside a frame: their centre lies in its rectangle.\n * Frames nested in it count, and so does what they hold. */\nexport function frameContents(board, frameId) {\n const { nodes } = readBoard(board);\n const frame = nodes.find((n) => n.id === frameId);\n if (!frame) return [];\n const r = nodeRect(frame);\n return nodes.filter((n) => n.id !== frameId && inside(center(nodeRect(n)), r) && area(nodeRect(n)) < area(r)).map((n) => n.id);\n}\nconst area = (r) => r.width * r.height;\n\n/** The smallest frame each node sits in: Map(nodeId → frameId). */\nexport function frameOf(board) {\n const { nodes } = readBoard(board);\n const frames = nodes.filter((n) => n.type === \"frame\").map((f) => ({ id: f.id, r: nodeRect(f) }));\n const out = new Map();\n for (const n of nodes) {\n const c = center(nodeRect(n));\n const a = area(nodeRect(n));\n let best = null;\n for (const f of frames) {\n if (f.id === n.id || !inside(c, f.r) || area(f.r) <= a) continue;\n if (!best || area(f.r) < area(best.r)) best = f;\n }\n if (best) out.set(n.id, best.id);\n }\n return out;\n}\n\n/** Round a point onto the grid. */\nexport function snapPoint(p, grid = GRID) {\n if (!grid) return { x: p.x, y: p.y };\n return { x: Math.round(p.x / grid) * grid, y: Math.round(p.y / grid) * grid };\n}\n\n/**\n * Put a rectangle into a cell — for boards with lanes or a grid (a journey\n * map's stage × row). `cells` is [{ id, x, y, width, height }]. The cell\n * is the one holding the rectangle's centre, else the nearest; the\n * rectangle is kept inside it with `pad` to spare. Returns\n * { x, y, cellId } or null when there are no cells.\n */\nexport function snapToCell(rect, cells, { pad = 8 } = {}) {\n const list = (cells ?? []).filter((c) => c && num(c.width) > 0 && num(c.height) > 0);\n if (!list.length) return null;\n const c = center(rect);\n let cell = list.find((k) => inside(c, k));\n if (!cell) {\n let best = Infinity;\n for (const k of list) {\n const dx = Math.max(k.x - c.x, 0, c.x - (k.x + k.width));\n const dy = Math.max(k.y - c.y, 0, c.y - (k.y + k.height));\n const d = dx * dx + dy * dy;\n if (d < best) {\n best = d;\n cell = k;\n }\n }\n }\n const clamp = (v, lo, hi) => (hi < lo ? (lo + hi) / 2 : Math.min(Math.max(v, lo), hi));\n return {\n x: clamp(rect.x, cell.x + pad, cell.x + cell.width - pad - rect.width),\n y: clamp(rect.y, cell.y + pad, cell.y + cell.height - pad - rect.height),\n cellId: cell.id,\n };\n}\n\n/** Where a connector leaves and enters: the sides of two rectangles that\n * face each other. → { source: \"right\", target: \"left\" } */\nexport function facingSides(a, b) {\n const ca = center(a);\n const cb = center(b);\n const dx = cb.x - ca.x;\n const dy = cb.y - ca.y;\n const horizontal = Math.abs(dx) / Math.max(1, a.width + b.width) >= Math.abs(dy) / Math.max(1, a.height + b.height);\n if (horizontal) return dx >= 0 ? { source: \"right\", target: \"left\" } : { source: \"left\", target: \"right\" };\n return dy >= 0 ? { source: \"bottom\", target: \"top\" } : { source: \"top\", target: \"bottom\" };\n}\n\n/** The midpoint of one side of a rectangle. */\nexport function sidePoint(r, side) {\n if (side === \"left\") return { x: r.x, y: r.y + r.height / 2 };\n if (side === \"right\") return { x: r.x + r.width, y: r.y + r.height / 2 };\n if (side === \"top\") return { x: r.x + r.width / 2, y: r.y };\n return { x: r.x + r.width / 2, y: r.y + r.height };\n}\n\n/**\n * The biggest font size (px) at which `text` fits a box — a sticky's words\n * shrink as they grow. An estimate (no DOM): average glyph width `charWidth`\n * em, lines wrap at words.\n */\nexport function fitFontSize(text, width, height, { min = 12, max = 30, lineHeight = 1.25, charWidth = 0.56 } = {}) {\n const words = String(text ?? \"\").trim();\n if (!words) return max;\n const paras = words.split(/\\n/);\n const fits = (s) => {\n const perLine = Math.max(1, Math.floor(width / (s * charWidth)));\n let lines = 0;\n for (const p of paras) {\n let line = 0;\n let count = 1;\n for (const w of p.split(/\\s+/).filter(Boolean)) {\n if (w.length > perLine) {\n // A long word breaks across lines.\n const extra = Math.ceil(w.length / perLine);\n count += line ? extra : extra - 1;\n line = w.length % perLine;\n continue;\n }\n const need = line ? line + 1 + w.length : w.length;\n if (need > perLine) {\n count += 1;\n line = w.length;\n } else line = need;\n }\n lines += count;\n }\n return lines * s * lineHeight <= height;\n };\n let lo = min;\n let hi = max;\n if (fits(hi)) return hi;\n if (!fits(lo)) return lo;\n while (hi - lo > 0.5) {\n const mid = (lo + hi) / 2;\n if (fits(mid)) lo = mid;\n else hi = mid;\n }\n return Math.floor(lo);\n}\n\n// ── making and changing nodes ────────────────────────────────────────────\n\nconst DATA = {\n sticky: () => ({ text: \"\", color: \"warning\" }),\n shape: () => ({ text: \"\", shape: \"rect\", color: \"muted\", textSize: \"md\" }),\n text: () => ({ text: \"\", textSize: \"md\" }),\n frame: () => ({ title: \"Frame\" }),\n card: () => ({ title: \"\", body: \"\" }),\n image: () => ({ src: \"\", alt: \"\" }),\n};\n\n/**\n * A new node CENTRED on `point`, snapped to the grid, with its type's\n * defaults (size, data). Custom types take `width`/`height` from opts\n * (default 200×120).\n *\n * createNode(\"sticky\", { x: 400, y: 200 }, { data: { text: \"Idea\", color: \"chart-3\" } })\n * createNode(\"person\", p, { width: 240, data: { name: \"New person\" } })\n *\n * With `rect` ({ x, y, width, height }) it fills that rectangle instead\n * (a shape dragged out with the tool).\n */\nexport function createNode(type, point, { id, data, width, height, rect, grid = GRID, ...rest } = {}) {\n const base = SIZES[type] ?? { width: 200, height: 120 };\n const kind = type === \"shape\" ? data?.shape ?? \"rect\" : null;\n const [sw, sh] = kind ? SHAPE_SIZES[kind] ?? SHAPE_SIZES.rect : [];\n let w = num(rect?.width, num(width, sw ?? base.width));\n let h = rect ? num(rect.height) : height !== undefined ? height : sh ?? base.height;\n if (AUTO_HEIGHT.has(type) && !rect && height === undefined) h = undefined;\n const at = rect ? { x: rect.x, y: rect.y } : { x: num(point?.x) - w / 2, y: num(point?.y) - num(h, 40) / 2 };\n const node = {\n id: id ?? makeId(type),\n type,\n position: snapPoint(at, grid),\n data: { ...(DATA[type]?.() ?? {}), ...(data ?? {}) },\n ...rest,\n };\n if (w !== undefined) node.width = grid ? Math.max(grid, Math.round(w / grid) * grid) : w;\n if (h !== undefined) node.height = grid ? Math.max(grid, Math.round(h / grid) * grid) : h;\n if (type === \"frame\" && node.zIndex === undefined) node.zIndex = -1;\n return node;\n}\n\n/** A connector. `data`: { line: curved|straight|elbow, arrow: end|both|none, dashed }. */\nexport function createEdge(source, target, { id, label, data } = {}) {\n const e = { id: id ?? makeId(\"e\"), source, target, type: \"flow\", data: { line: \"curved\", arrow: \"end\", ...(data ?? {}) } };\n if (label) e.label = label;\n return e;\n}\n\n/**\n * Copies of some nodes (and the connectors between them), moved by\n * `offset`, with new ids. Frames bring what they hold. Returns\n * { nodes, edges } — only the new ones.\n */\nexport function duplicateNodes(board, ids, { offset = { x: GRID * 2, y: GRID * 2 } } = {}) {\n const { nodes, edges } = readBoard(board);\n const want = new Set(ids);\n for (const id of ids) {\n const n = nodes.find((x) => x.id === id);\n if (n?.type === \"frame\") for (const c of frameContents(board, id)) want.add(c);\n }\n const map = new Map();\n const out = [];\n for (const n of nodes) {\n if (!want.has(n.id)) continue;\n const id = makeId(n.type || \"n\");\n map.set(n.id, id);\n const { selected, dragging, measured, ...rest } = n;\n const copy = { ...rest, id, position: { x: num(n.position?.x) + offset.x, y: num(n.position?.y) + offset.y }, data: structuredCopy(n.data) };\n if (copy.data?.locked) {\n delete copy.data.locked;\n delete copy.draggable;\n delete copy.deletable;\n }\n out.push(copy);\n }\n const outEdges = edges\n .filter((e) => map.has(e.source) && map.has(e.target))\n .map((e) => ({ ...e, id: makeId(\"e\"), source: map.get(e.source), target: map.get(e.target), selected: undefined }));\n return { nodes: out, edges: outEdges };\n}\n\nfunction structuredCopy(v) {\n try {\n return JSON.parse(JSON.stringify(v ?? {}));\n } catch {\n return { ...(v ?? {}) };\n }\n}\n\n/** The board without some nodes and every connector that touched them. */\nexport function removeNodes(board, ids) {\n const { nodes, edges } = readBoard(board);\n const gone = new Set(ids);\n return { ...board, nodes: nodes.filter((n) => !gone.has(n.id)), edges: edges.filter((e) => !gone.has(e.source) && !gone.has(e.target)) };\n}\n\n/**\n * Line up nodes: how = left | center | right | top | middle | bottom.\n * Returns the whole node list with those moved.\n */\nexport function alignNodes(nodes, ids, how) {\n const set = new Set(ids);\n const pick = (nodes ?? []).filter((n) => set.has(n.id));\n const b = boardBounds(pick);\n if (!b || pick.length < 2) return nodes;\n return nodes.map((n) => {\n if (!set.has(n.id) || n.data?.locked) return n;\n const r = nodeRect(n);\n const p = { ...n.position };\n if (how === \"left\") p.x = b.x;\n else if (how === \"center\") p.x = b.x + b.width / 2 - r.width / 2;\n else if (how === \"right\") p.x = b.x + b.width - r.width;\n else if (how === \"top\") p.y = b.y;\n else if (how === \"middle\") p.y = b.y + b.height / 2 - r.height / 2;\n else if (how === \"bottom\") p.y = b.y + b.height - r.height;\n return { ...n, position: p };\n });\n}\n\n/** Even gaps between nodes along an axis: \"horizontal\" | \"vertical\". */\nexport function distributeNodes(nodes, ids, axis = \"horizontal\") {\n const set = new Set(ids);\n const h = axis === \"horizontal\";\n const pick = (nodes ?? []).filter((n) => set.has(n.id)).map((n) => ({ n, r: nodeRect(n) }));\n if (pick.length < 3) return nodes;\n pick.sort((a, b) => (h ? a.r.x - b.r.x : a.r.y - b.r.y));\n const first = pick[0].r;\n const last = pick[pick.length - 1].r;\n const span = h ? last.x + last.width - first.x : last.y + last.height - first.y;\n const used = pick.reduce((s, p) => s + (h ? p.r.width : p.r.height), 0);\n const gap = (span - used) / (pick.length - 1);\n const at = new Map();\n let cursor = h ? first.x : first.y;\n for (const p of pick) {\n at.set(p.n.id, cursor);\n cursor += (h ? p.r.width : p.r.height) + gap;\n }\n return nodes.map((n) => (at.has(n.id) && !n.data?.locked ? { ...n, position: h ? { ...n.position, x: at.get(n.id) } : { ...n.position, y: at.get(n.id) } } : n));\n}\n\n/** Arrange: move nodes to the end (front) or start (back) of the list.\n * Paint order is list order; frames always sit under everything. */\nexport function arrangeNodes(nodes, ids, where = \"front\") {\n const set = new Set(ids);\n const moved = nodes.filter((n) => set.has(n.id));\n const rest = nodes.filter((n) => !set.has(n.id));\n return where === \"back\" ? [...moved, ...rest] : [...rest, ...moved];\n}\n\n// ── layout ───────────────────────────────────────────────────────────────\n\n/**\n * A layered layout (sources first, each step one rank further) with no\n * dependency: cycles are broken, ranks are longest paths, and a few\n * barycentre sweeps untangle crossings. Nodes nobody connects sit in a row\n * after the rest. The result keeps the top-left corner the nodes had, so\n * nothing flies off screen.\n *\n * autoLayout(nodes, edges, { direction: \"LR\" | \"TB\", rankGap: 80, nodeGap: 40, ids })\n *\n * `ids` lays out only those nodes (default: every node but frames).\n * Returns the whole node list with new positions.\n */\nexport function autoLayout(nodes, edges, { direction = \"LR\", rankGap = 80, nodeGap = 40, ids, grid = GRID } = {}) {\n const all = nodes ?? [];\n const pickIds = new Set(ids ?? all.filter((n) => n.type !== \"frame\").map((n) => n.id));\n const pick = all.filter((n) => pickIds.has(n.id));\n if (!pick.length) return all;\n const before = boardBounds(pick);\n const lr = direction !== \"TB\";\n const size = new Map(pick.map((n) => [n.id, nodeSize(n)]));\n const main = (id) => (lr ? size.get(id).width : size.get(id).height);\n const cross = (id) => (lr ? size.get(id).height : size.get(id).width);\n\n const order = pick.map((n) => n.id);\n const index = new Map(order.map((id, i) => [id, i]));\n const links = (edges ?? []).filter((e) => pickIds.has(e.source) && pickIds.has(e.target) && e.source !== e.target);\n\n // Break cycles: a DFS in list order; an edge back into the stack is reversed.\n const out = new Map(order.map((id) => [id, []]));\n for (const e of links) out.get(e.source).push(e.target);\n const state = new Map();\n const dag = [];\n const visit = (u) => {\n state.set(u, 1);\n for (const v of out.get(u)) {\n if (state.get(v) === 1) dag.push([v, u]);\n else {\n dag.push([u, v]);\n if (!state.get(v)) visit(v);\n }\n }\n state.set(u, 2);\n };\n for (const id of order) if (!state.get(id)) visit(id);\n\n const preds = new Map(order.map((id) => [id, []]));\n const succs = new Map(order.map((id) => [id, []]));\n const seen = new Set();\n for (const [u, v] of dag) {\n const k = `${u}>${v}`;\n if (seen.has(k)) continue;\n seen.add(k);\n succs.get(u).push(v);\n preds.get(v).push(u);\n }\n\n // Components: connected groups, laid side by side on the cross axis.\n const comp = new Map();\n let compCount = 0;\n for (const id of order) {\n if (comp.has(id)) continue;\n const stack = [id];\n comp.set(id, compCount);\n while (stack.length) {\n const u = stack.pop();\n for (const v of [...succs.get(u), ...preds.get(u)]) if (!comp.has(v)) (comp.set(v, compCount), stack.push(v));\n }\n compCount++;\n }\n\n // Ranks: longest path from a source (Kahn order).\n const rank = new Map();\n const indeg = new Map(order.map((id) => [id, preds.get(id).length]));\n const queue = order.filter((id) => indeg.get(id) === 0);\n for (const id of queue) rank.set(id, 0);\n for (let i = 0; i < queue.length; i++) {\n const u = queue[i];\n for (const v of succs.get(u)) {\n rank.set(v, Math.max(rank.get(v) ?? 0, rank.get(u) + 1));\n indeg.set(v, indeg.get(v) - 1);\n if (indeg.get(v) === 0) queue.push(v);\n }\n }\n\n const loose = order.filter((id) => !preds.get(id).length && !succs.get(id).length);\n const looseSet = new Set(loose);\n const groups = [];\n for (let c = 0; c < compCount; c++) {\n const members = order.filter((id) => comp.get(id) === c && !looseSet.has(id));\n if (members.length) groups.push(members);\n }\n\n const positions = new Map();\n let crossCursor = 0;\n for (const members of groups) {\n const maxRank = Math.max(...members.map((id) => rank.get(id)));\n const layers = Array.from({ length: maxRank + 1 }, () => []);\n for (const id of members) layers[rank.get(id)].push(id);\n for (const l of layers) l.sort((a, b) => index.get(a) - index.get(b));\n // Barycentre sweeps: order each layer by the mean slot of its neighbours.\n const slot = new Map();\n const reslot = () => layers.forEach((l) => l.forEach((id, i) => slot.set(id, i)));\n reslot();\n const bary = (id, nbrs) => {\n const s = nbrs.filter((x) => slot.has(x));\n return s.length ? s.reduce((t, x) => t + slot.get(x), 0) / s.length : slot.get(id);\n };\n for (let pass = 0; pass < 4; pass++) {\n for (let r = 1; r < layers.length; r++) {\n layers[r].sort((a, b) => bary(a, preds.get(a)) - bary(b, preds.get(b)) || index.get(a) - index.get(b));\n reslot();\n }\n for (let r = layers.length - 2; r >= 0; r--) {\n layers[r].sort((a, b) => bary(a, succs.get(a)) - bary(b, succs.get(b)) || index.get(a) - index.get(b));\n reslot();\n }\n }\n // Place: ranks along the main axis, each layer centred on the widest.\n const extent = layers.map((l) => l.reduce((s, id) => s + cross(id), 0) + nodeGap * Math.max(0, l.length - 1));\n const widest = Math.max(...extent);\n let mainCursor = 0;\n layers.forEach((l, r) => {\n const depth = Math.max(...l.map(main));\n let c = crossCursor + (widest - extent[r]) / 2;\n for (const id of l) {\n const m = mainCursor + (depth - main(id)) / 2;\n positions.set(id, lr ? { x: m, y: c } : { x: c, y: m });\n c += cross(id) + nodeGap;\n }\n mainCursor += depth + rankGap;\n });\n crossCursor += widest + rankGap;\n }\n // The unconnected ones: one row (or column) after the groups.\n if (loose.length) {\n let m = 0;\n const depth = Math.max(...loose.map(cross));\n for (const id of loose) {\n positions.set(id, lr ? { x: m, y: crossCursor + (depth - cross(id)) / 2 } : { x: crossCursor + (depth - cross(id)) / 2, y: m });\n m += main(id) + nodeGap;\n }\n }\n\n // Keep the old top-left corner.\n const laid = pick.map((n) => ({ ...n, position: positions.get(n.id) }));\n const after = boardBounds(laid);\n const dx = before.x - after.x;\n const dy = before.y - after.y;\n const moved = new Map(laid.map((n) => [n.id, snapPoint({ x: n.position.x + dx, y: n.position.y + dy }, grid)]));\n return all.map((n) => (moved.has(n.id) && !n.data?.locked ? { ...n, position: moved.get(n.id) } : n));\n}\n\n// ── words out ────────────────────────────────────────────────────────────\n\n/** The words a node shows: text, title, label or name. */\nexport function nodeText(node) {\n const d = node?.data ?? {};\n for (const k of [\"text\", \"title\", \"label\", \"name\"]) if (typeof d[k] === \"string\" && d[k].trim()) return d[k].trim();\n if (node?.type === \"image\") return String(d.alt || d.caption || \"\").trim();\n return \"\";\n}\n\nconst KIND = { sticky: \"Sticky\", shape: \"Shape\", text: \"Text\", frame: \"Frame\", card: \"Card\", image: \"Image\" };\nconst quote = (s) => `\"${String(s).replace(/\\s+/g, \" \").trim()}\"`;\nconst readingOrder = (a, b) => {\n const ra = nodeRect(a);\n const rb = nodeRect(b);\n return Math.abs(ra.y - rb.y) > 24 ? ra.y - rb.y : ra.x - rb.x;\n};\n\n/** One line for a node: `Sticky (yellow): \"Ship the beta\"`. */\nexport function describeNode(node, describe) {\n const own = describe?.(node);\n if (typeof own === \"string\" && own) return own;\n const d = node?.data ?? {};\n const text = nodeText(node);\n if (node?.type === \"shape\") return `${SHAPE_NAMES[d.shape] ?? \"Shape\"}: ${text ? quote(text) : \"(empty)\"}`;\n if (node?.type === \"sticky\") {\n const extra = [d.badge != null && d.badge !== \"\" ? `${d.badge}` : null, d.meta || null].filter(Boolean).join(\", \");\n return `Sticky: ${text ? quote(text) : \"(empty)\"}${extra ? ` [${extra}]` : \"\"}`;\n }\n if (node?.type === \"card\") return `Card${d.kind ? ` (${d.kind})` : \"\"}: ${text ? quote(text) : \"(untitled)\"}${d.body ? ` — ${String(d.body).replace(/\\s+/g, \" \").trim()}` : \"\"}`;\n if (node?.type === \"image\") return `Image: ${text ? quote(text) : \"(no description)\"}`;\n const kind = KIND[node?.type] ?? String(node?.type ?? \"Item\").replace(/^\\w/, (c) => c.toUpperCase());\n return `${kind}: ${text ? quote(text) : \"(empty)\"}`;\n}\n\nfunction outline(board) {\n const { nodes, edges } = readBoard(board);\n const parent = frameOf(board);\n const kids = new Map();\n for (const n of nodes) {\n const p = parent.get(n.id) ?? null;\n if (!kids.has(p)) kids.set(p, []);\n kids.get(p).push(n);\n }\n for (const list of kids.values()) list.sort((a, b) => (a.type === \"frame\") - (b.type === \"frame\") || readingOrder(a, b));\n const byId = new Map(nodes.map((n) => [n.id, n]));\n return { nodes, edges, kids, byId };\n}\n\nconst edgeLine = (e, byId) => {\n const a = nodeText(byId.get(e.source)) || byId.get(e.source)?.type || e.source;\n const b = nodeText(byId.get(e.target)) || byId.get(e.target)?.type || e.target;\n const arrow = e.data?.arrow === \"both\" ? \"↔\" : e.data?.arrow === \"none\" ? \"—\" : \"→\";\n return `${quote(a)} ${arrow} ${quote(b)}${e.label ? ` (${String(e.label).trim()})` : \"\"}`;\n};\n\n/**\n * The board as plain text, for an agent or a screen reader: frames with\n * what they hold (in reading order, nested), loose items, connectors.\n * `describe(node)` returns a line for a custom node type (or nothing, for\n * the default).\n */\nexport function boardToText(board, { describe } = {}) {\n const { nodes, edges, kids, byId } = outline(board);\n if (!nodes.length) return \"Empty board.\";\n const lines = [`Board: ${nodes.length} item${nodes.length === 1 ? \"\" : \"s\"}, ${edges.length} connector${edges.length === 1 ? \"\" : \"s\"}.`];\n const walk = (parentId, depth) => {\n for (const n of kids.get(parentId) ?? []) {\n const pad = \" \".repeat(depth);\n if (n.type === \"frame\") {\n const count = (kids.get(n.id) ?? []).length;\n lines.push(`${pad}Frame ${quote(n.data?.title || \"Untitled\")} (${count} item${count === 1 ? \"\" : \"s\"}):`);\n walk(n.id, depth + 1);\n } else lines.push(`${pad}- ${describeNode(n, describe)}`);\n }\n };\n walk(null, 0);\n if (edges.length) {\n lines.push(\"Connectors:\");\n for (const e of edges) lines.push(`- ${edgeLine(e, byId)}`);\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * The board as GitHub markdown: `# title`, a heading per frame (nested\n * frames one level down), a bullet per item, a Connections list.\n */\nexport function boardToMarkdown(board, { title, describe } = {}) {\n const { nodes, edges, kids, byId } = outline(board);\n const out = [];\n if (title) out.push(`# ${title}`, \"\");\n if (!nodes.length) return [...out, \"_Empty board._\"].join(\"\\n\");\n const esc = (s) => String(s).replace(/([*_`[\\]])/g, \"\\\\$1\").replace(/\\s+/g, \" \").trim();\n const bullet = (n) => {\n const own = describe?.(n);\n if (typeof own === \"string\" && own) return `- ${own}`;\n const d = n.data ?? {};\n const text = nodeText(n);\n if (n.type === \"text\") return `- ${esc(text) || \"_(empty)_\"}`;\n if (n.type === \"shape\") return `- **${esc(text) || \"(empty)\"}** _(${(SHAPE_NAMES[d.shape] ?? \"shape\").toLowerCase()})_`;\n if (n.type === \"card\") return `- **${esc(text) || \"(untitled)\"}**${d.body ? ` — ${esc(d.body)}` : \"\"}`;\n if (n.type === \"image\") return d.src ? `- ` : `- _image: ${esc(text)}_`;\n const extra = n.type === \"sticky\" && d.badge != null && d.badge !== \"\" ? ` (${d.badge})` : \"\";\n return `- ${esc(text) || \"_(empty)_\"}${extra}`;\n };\n const loose = (kids.get(null) ?? []).filter((n) => n.type !== \"frame\");\n for (const n of loose) out.push(bullet(n));\n if (loose.length) out.push(\"\");\n const walk = (parentId, level) => {\n for (const f of (kids.get(parentId) ?? []).filter((n) => n.type === \"frame\")) {\n out.push(`${\"#\".repeat(Math.min(6, level))} ${esc(f.data?.title || \"Untitled\")}`, \"\");\n const items = (kids.get(f.id) ?? []).filter((n) => n.type !== \"frame\");\n for (const n of items) out.push(bullet(n));\n if (items.length) out.push(\"\");\n walk(f.id, level + 1);\n }\n };\n walk(null, title ? 2 : 1);\n if (edges.length) {\n out.push(`${\"#\".repeat(title ? 2 : 1)} Connections`, \"\");\n for (const e of edges) out.push(`- ${edgeLine(e, byId)}`);\n out.push(\"\");\n }\n return out.join(\"\\n\").trimEnd() + \"\\n\";\n}\n\n// ── history ──────────────────────────────────────────────────────────────\n\n/** Undo/redo as a reducer over board snapshots (use cleanBoard ones).\n * const [h, dispatch] = useReducer(historyReducer, board, historyInit)\n * dispatch({ type: \"push\", present }) a committed change (a no-op when equal)\n * dispatch({ type: \"undo\" }) · dispatch({ type: \"redo\" })\n * dispatch({ type: \"reset\", present }) a new document; no undo past it\n * h.present is the board to show; h.past / h.future the stacks. */\nexport function historyInit(present) {\n const p = cleanBoard(present ?? emptyBoard(), { viewport: false });\n return { past: [], present: p, future: [], key: boardKey(p) };\n}\n\nexport function historyReducer(h, action) {\n const limit = action?.limit ?? 100;\n switch (action?.type) {\n case \"push\": {\n const present = cleanBoard(action.present, { viewport: false });\n const key = boardKey(present);\n if (key === h.key) return h;\n return { past: [...h.past, h.present].slice(-limit), present, future: [], key };\n }\n case \"undo\": {\n if (!h.past.length) return h;\n const present = h.past[h.past.length - 1];\n return { past: h.past.slice(0, -1), present, future: [h.present, ...h.future].slice(0, limit), key: boardKey(present) };\n }\n case \"redo\": {\n if (!h.future.length) return h;\n const [present, ...future] = h.future;\n return { past: [...h.past, h.present].slice(-limit), present, future, key: boardKey(present) };\n }\n case \"reset\":\n return historyInit(action.present);\n default:\n return h;\n }\n}\n\n// ── starters ─────────────────────────────────────────────────────────────\n\n/** The empty-state starters FlowCanvas offers by default. Each `board()`\n * makes fresh ids. Pass your own list in `templates`, or [] for none. */\nexport function starterTemplates() {\n return [\n {\n id: \"brainstorm\",\n label: \"Brainstorm\",\n description: \"A frame and a few stickies to start from\",\n icon: \"lightbulb\",\n board: () => {\n const f = createNode(\"frame\", null, { rect: { x: 0, y: 0, width: 720, height: 420 }, data: { title: \"Ideas\" } });\n const s = (x, y, color, text) => createNode(\"sticky\", { x, y }, { data: { color, text } });\n return {\n nodes: [\n f,\n s(140, 160, \"warning\", \"What would make this a yes?\"),\n s(360, 160, \"chart-2\", \"\"),\n s(580, 160, \"chart-3\", \"\"),\n createNode(\"text\", { x: 360, y: 360 }, { width: 480, data: { text: \"Add one idea per sticky. Cluster them later.\", textSize: \"sm\" } }),\n ],\n edges: [],\n };\n },\n },\n {\n id: \"flowchart\",\n label: \"Flowchart\",\n description: \"Start, steps, a decision, connected\",\n icon: \"git-branch\",\n board: () => {\n const sh = (x, y, shape, text, color) => createNode(\"shape\", { x, y }, { data: { shape, text, color } });\n const a = sh(100, 200, \"pill\", \"Start\", \"chart-2\");\n const b = sh(340, 200, \"rect\", \"Do the thing\", \"muted\");\n const c = sh(580, 200, \"diamond\", \"Worked?\", \"warning\");\n const d = sh(820, 200, \"pill\", \"Done\", \"success\");\n return {\n nodes: [a, b, c, d],\n edges: [createEdge(a.id, b.id), createEdge(b.id, c.id), createEdge(c.id, d.id, { label: \"Yes\" }), createEdge(c.id, b.id, { label: \"No\", data: { line: \"elbow\" } })],\n };\n },\n },\n {\n id: \"retro\",\n label: \"Retro\",\n description: \"Went well, to improve, actions\",\n icon: \"history\",\n board: () => {\n const col = (i, title, color) => {\n const x = i * 340;\n return [\n createNode(\"frame\", null, { rect: { x, y: 0, width: 320, height: 460 }, data: { title } }),\n createNode(\"sticky\", { x: x + 160, y: 140 }, { width: 180, height: 180, data: { color, text: \"\" } }),\n ];\n };\n return { nodes: [...col(0, \"Went well\", \"success\"), ...col(1, \"To improve\", \"warning\"), ...col(2, \"Actions\", \"chart-5\")], edges: [] };\n },\n },\n ];\n}\n"
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"path": "registry/blocks/flow-canvas/theme.js",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"path": "registry/blocks/flow-canvas/README.md",
|
|
64
64
|
"type": "registry:file",
|
|
65
65
|
"target": "@components/makersclaw/blocks/flow-canvas/README.md",
|
|
66
|
-
"content": "# FlowCanvas\n\nA Miro-like board in the workspace theme. Sticky notes, shapes, text,\nframes, cards and images on an infinite dotted canvas. A tool rail on the\nleft (a bar along the bottom on a narrow canvas), double-click anywhere\nfor a sticky, drag from a card's dot to another card to connect them —\nor drop on empty canvas to pick what to add there. A floating toolbar\nover the selection, a right-click menu, undo/redo, copy/paste, snap to\ngrid, a minimap, a list view for keyboards and screen readers, and a\npresentation mode that steps through the frames.\n\nIt is [React Flow](https://reactflow.dev) (`@xyflow/react`, MIT)\nunderneath, with its colours mapped onto the workspace's tokens. The\ncanvas blocks — `account-map`, `process-map`, `journey-map`, `idea-board`\n— are built on this one: they import it, never `@xyflow/react`.\n\n```jsx\nimport { FlowCanvas, boardToText } from \"../components/blocks/flow-canvas\"; // a Makersclaw app (\"./blocks/flow-canvas\" from components/)\nimport { FlowCanvas, boardToText } from \"@/components/makersclaw/blocks/flow-canvas\"; // a shadcn project\n```\n\n`FlowCanvas` is a client component: render it from a `\"use client\"`\ncomponent. The helpers (`boardToText`, `autoLayout`, `createNode`…) are\nplain functions and work on the server too.\n\n`makersclaw add flow-canvas` adds `@xyflow/react` (pinned, exact) to\npackage.json: run `npm install` after. It loads with the page (no\n`import()`): the board mounts after the first render; until then — on the\nserver — the box holds the board as text for a screen reader.\n\n## The data\n\nA board is plain JSON. Store it as it comes out of `onChange` (one `jsonb`\ncolumn).\n\n```js\n{\n nodes: [\n { id: \"s1\", type: \"sticky\", position: { x: 40, y: 80 }, width: 200, height: 200,\n data: { text: \"Interview churned customers\", color: \"warning\", badge: 3, meta: \"Priya\" } },\n { id: \"f1\", type: \"frame\", position: { x: 0, y: 0 }, width: 720, height: 420, zIndex: -1,\n data: { title: \"Discover\", color: \"chart-2\" } },\n ],\n edges: [\n { id: \"e1\", source: \"s1\", target: \"s2\", label: \"feeds\",\n data: { line: \"curved\", arrow: \"end\", dashed: false } },\n ],\n viewport: { x: 0, y: 0, zoom: 1 }, // where the person last looked; optional\n}\n```\n\n- **Positions are absolute**, top-left corner, in board pixels. A card in a\n frame is not stored relative to it: a frame holds whatever lies inside\n its rectangle (`frameContents`, `frameOf`). Dragging a frame moves what\n is inside it.\n- **Colours are token names**, never colour values, so a board repaints\n with the theme: `\"warning\"` (the yellow sticky), `\"chart-1\"` … `\"chart-5\"`,\n `\"success\"`, `\"info\"`, `\"destructive\"`, `\"muted\"`. `COLORS` lists them;\n `STICKY_COLORS` is the sticky palette.\n- **Paint order is list order** (later is on top); frames always sit\n under everything (`zIndex: -1`).\n- `width`/`height` are set by resizing; `text` and `card` nodes leave\n `height` out and grow with their content.\n- A node may carry React Flow's own flags: `draggable: false`,\n `deletable: false`, `selectable: false`, `connectable: false`. Locking\n (the toolbar's lock) sets `data.locked: true` with `draggable` and\n `deletable` false.\n\nThe built-in node types and their `data`:\n\n| type | data |\n| --- | --- |\n| `sticky` | `text`, `color` (default `\"warning\"`), `badge?` (a count in the corner: votes), `meta?` (a small line at the foot: an author) |\n| `shape` | `text`, `shape`: `rect` · `ellipse` · `diamond` · `pill`, `color` (default `\"muted\"`), `textSize`: `sm` · `md` · `lg` · `xl` |\n| `text` | `text`, `textSize`, `align?`: `left` · `center` · `right` |\n| `frame` | `title`, `color?` (a faint wash and edge; none = the card surface) |\n| `card` | `title`, `body?`, `icon?` (a kit icon name), `badge?`, `color?` (a strip on top), `kind?`, anything else your `renderCard` reads |\n| `image` | `src`, `alt` |\n\nConnectors: `data.line` `curved` (default) · `straight` · `elbow`;\n`data.arrow` `end` (default) · `both` · `none`; `data.dashed`. They leave\nand enter by the sides of the two cards that face each other, so they\nre-route as cards move; handle ids are not stored.\n\n## Props\n\n- `value` — the board, controlled. A new `value` that differs from what\n the canvas last sent through `onChange` replaces it (loading another\n board, or a change made elsewhere — a vote count); that replacement can\n be undone. Pass back exactly what `onChange` gave you and nothing resets.\n- `defaultValue` — the starting board, uncontrolled. With neither, it\n starts empty (and shows the empty state).\n- `onChange(board)` — after each committed change (a drag ends, a card is\n placed, words stop being edited, a colour is picked), debounced by\n `changeDelay` (default 400 ms). The board is clean JSON (see above).\n Selection, typing in progress and panning never call it.\n- `onSave(board)` — `async`, return `{ ok: true }` or `{ ok: false, error }`\n (a throw counts as failed). Called after `onChange`, and on ⌘S. The\n canvas shows \"Saving…\", \"Saved\", or the error with a Retry beside\n undo/redo. Leave it out when `onChange` already saves.\n- `changeDelay` — ms, default 400.\n- `readOnly` — `false` (default). `true`: no rail, no editing, no\n connecting; pan, zoom, fit, the list view and `onOpenNode` still work.\n- `presentation` — `false` (default). `true`: read-only, and a bar steps\n through the frames in reading order (← → or the buttons; each fills the\n screen). `onExitPresentation()` — Esc and the bar's Exit call it; turn\n `presentation` off there.\n- `height` — the canvas's height, default `560` (px; any CSS length works:\n `\"70vh\"`, `\"100%\"` inside a sized box).\n- `bare` — `false` (default: rounded, bordered). `true`: no border or\n radius, for a full-page board.\n- `label` — the board's accessible name, default `\"Board\"`; also the list\n view's heading.\n- `className` — layout around it (margin, width). Never colour.\n- `palette` — the sticky colours offered, token names, default\n `STICKY_COLORS` (six). The toolbar adds a few more for shapes and frames.\n- `snap` — `true` (default): positions and sizes snap to `grid` (default\n `20` px — the dot spacing). The person can turn it off from the view menu.\n- `minimap` — `false` (default): whether the minimap starts open (a toggle\n in the zoom bar either way; hidden on a narrow canvas).\n- `hideAttribution` — `false` (default). React Flow asks that its small\n credit stays; its MIT licence allows hiding it.\n- `canvasRef` — a ref (`useFlowCanvas()`) that receives the handle (below).\n- `onSelectionChange(nodes, edges)` — the selection changed (a side panel\n for the selected person).\n- `onOpenNode(node)` — double-click or Enter on a node that has no words of\n its own to edit: your own node types, cards drawn by `renderCard`, and\n every node in read-only mode. Open your `Sheet` there. Without it,\n double-click edits the words.\n- `describeNode(node)` — a one-line description of one of YOUR node types,\n for the list view, `boardToText` and the server placeholder; return\n nothing for the default (`Sticky: \"…\"`).\n\nExtending it — how a block makes the canvas its own:\n\n- `nodeTypes` — `{ person: PersonNode, … }`: your own node components,\n added to the built-in ones (a key that matches a built-in replaces it).\n See \"Your own node types\".\n- `renderCard(node, ctx)` — draws the inside of every `card` node; return\n `undefined` for the default (title + body). `ctx`: `{ selected, editing,\n update(patch), api }`. Kit parts only (Avatar, Badge, Stat…); give an\n interactive part inside `className=\"nodrag\"` so pressing it does not drag\n the card. The simplest way to put your own card on the board.\n- `tools` — more rail tools: `[{ id, label, icon, shortcut?, create?,\n onSelect?, sizable?, connectable? }]`. See \"Rail tools\".\n- `rail` — the rail's contents, in order: built-in ids (`\"select\"`,\n `\"hand\"`, `\"sticky\"`, `\"shape\"`, `\"text\"`, `\"connector\"`, `\"frame\"`,\n `\"image\"`), your tools' ids, and `\"|\"` for a divider. Default: all the\n built-ins, then your tools (and `\"image\"` when `pickImage` is set). A\n process map drops the sticky: `rail={[\"select\", \"hand\", \"|\", \"shape\", \"connector\", \"step\"]}`.\n- `pickImage()` — `async () => ({ src, alt, width?, height? }) | null`: turns\n on the image tool (your upload, your picker). Without it there is no\n image tool; image nodes in the data still show.\n- `topBar` — a node shown floating at the top centre: a title, a save\n button, a share menu, a timer.\n- `toolbarExtra(selection, api)` — more controls at the end of the\n floating toolbar over a selection (`selection.nodes`): kit\n `IconButton size=\"sm\"` / `Menu` parts. \"Vote\", \"Assign owner\".\n- `contextMenuItems(selection, api)` — more right-click items, kit `Menu`\n shape (`[{ label, onSelect, icon?, shortcut?, tone? }]`); `selection.nodes`\n is empty for a right-click on the empty canvas.\n- `emptyState` — a node in place of the first-run hint; `false` for none.\n- `templates` — the starters on the empty state: `[{ id, label, icon?,\n description?, board }]` (`board` a board or a function returning one).\n Default `starterTemplates()` (Brainstorm, Flowchart, Retro); `[]` for none.\n- `constrain(node, { reason, board })` — runs on every node a change\n touched before it is committed (`reason`: `\"move\"` after a drag or a\n nudge, `\"create\"`, `\"paste\"`, `\"resize\"`, `\"align\"`, `\"layout\"`). Return\n the node adjusted (a new position, size or data), or nothing to keep it.\n This is how a journey map snaps a sticky into its cell and records it.\n- `beforeChange(next, prev, reason)` — runs on the whole board at every\n commit (`next` and `prev` are clean boards). Return `false` to refuse the\n change (the board goes back), a board to commit instead, or nothing to\n accept it.\n- `canConnect(connection, board)` — `false` refuses a connector\n (`connection`: `{ source, target }`). A card never connects to itself.\n\n## The handle\n\n`const board = useFlowCanvas()`, `<FlowCanvas canvasRef={board} />`, then\n`board.current?.…`. Tools' `create`/`onSelect`, `renderCard`,\n`toolbarExtra` and `contextMenuItems` get the same object as `api`, and a\nnode component reaches it with `useCanvas().api`. Every change made through\nit commits (undoable, then `onChange`) unless `{ commit: false }` says not.\n\n- Read: `getBoard()` (clean JSON, with the viewport) · `getSelection()` →\n `{ nodes, edges }` · `toText()` · `toMarkdown({ title })` · `canUndo()` ·\n `canRedo()` · `getTool()` · `readOnly`\n- Nodes: `addNodes(node | nodes, { select = true, edit = false, edges })` →\n ids · `updateNode(id, patch | (node) => patch, { commit })` ·\n `updateNodeData(id, patch | (data) => patch, { commit })` ·\n `updateNodesData(ids, patch)` · `removeNodes(ids)` · `duplicate(ids)` ·\n `lock(ids, on)` · `arrange(ids, \"front\" | \"back\")` · `align(ids, \"left\" |\n \"center\" | \"right\" | \"top\" | \"middle\" | \"bottom\")` · `distribute(ids,\n \"horizontal\" | \"vertical\")` · `frameSelection(ids)`\n- Connectors: `addEdge({ source, target, label?, data? })` → id ·\n `updateEdge(id, patch, { commit })` · `removeEdges(ids)`\n- The whole board: `setBoard(board, { fit = true })` (undoable) ·\n `autoLayout({ direction: \"LR\" | \"TB\", ids?, rankGap?, nodeGap? })` ·\n `undo()` · `redo()` · `save()` (send now, skipping the debounce)\n- Editing: `edit(id)` (start editing a node's words) · `editEdge(id)` ·\n `stopEditing()` · `commit(reason, ids?)` (after `{ commit: false }` changes)\n- View: `select(ids, edgeIds?)` · `focusNode(id)` (select, centre, focus) ·\n `fitView({ nodes?: [{ id }] })` · `zoomIn()` · `zoomOut()` · `zoomTo(z)` ·\n `centerPoint()` (the board point at the middle of the screen — where a\n picker's choice lands) · `screenToFlow({ x, y })` · `setTool(id)`\n\n## Your own node types\n\nA node component gets React Flow's props: `{ id, type, data, selected,\nwidth, height, dragging }`. Wrap its body in `NodeShell` — it draws the\nselection box, the four connection dots (shown on hover), the resize\nhandles, the lock mark and the whole-card drop target while a connector is\ndragged. Without it (or `NodeHandles`), connectors cannot reach the node.\nRead the canvas with `useCanvas()`; change the node through `api`.\n\n```jsx\nimport { FlowCanvas, NodeShell, EditableText, useCanvas, createNode } from \"./blocks/flow-canvas\";\nimport { Avatar, Badge } from \"./ui\";\n\nfunction PersonNode({ id, data, selected }) {\n const { api, editingNode, readOnly } = useCanvas();\n return (\n <NodeShell id={id} selected={selected} locked={data.locked} resizable={false}>\n <div className=\"flex w-64 items-center gap-3 rounded-lg border border-border bg-card p-3 shadow-sm\">\n <Avatar name={data.name} src={data.avatar} />\n <div className=\"min-w-0 flex-1\">\n <EditableText\n singleLine\n editing={!readOnly && editingNode === id}\n value={data.name}\n onChange={(v) => api.updateNodeData(id, { name: v }, { commit: false })}\n onDone={() => api.stopEditing()}\n label=\"Name\"\n className=\"truncate text-sm font-medium\"\n />\n <p className=\"truncate text-xs text-muted-foreground\">{data.title}</p>\n </div>\n <Badge tone={data.role === \"blocker\" ? \"danger\" : \"neutral\"}>{data.role}</Badge>\n </div>\n </NodeShell>\n );\n}\nconst NODE_TYPES = { person: PersonNode }; // module level: a new object each render remounts every node\n\n<FlowCanvas nodeTypes={NODE_TYPES} describeNode={(n) => n.type === \"person\" ? `Person: ${n.data.name} (${n.data.role})` : undefined} … />\n```\n\n- `NodeShell` props: `id`, `selected`, `locked`, `resizable` (default\n true), `minWidth`/`minHeight` (40/30), `keepAspectRatio`, `autoHeight`\n (height follows content), `handles` (false: nothing connects), `ring`\n (false: draw your own selection), `className` (layout only).\n- `EditableText` props: `value`, `editing`, `onChange(text)`, `onDone()`,\n `placeholder`, `singleLine` (Enter finishes), `label`, `className`,\n `style`. Shown and edited in the same box, so nothing jumps.\n- Double-click and Enter call `onOpenNode(node)` for your types when you\n pass it, else they set `editingNode` to the node's id (use it as above).\n- Colour your node from a token in its data with `colorClasses(token)` →\n `{ tint, solid, fill, stroke, wash, edge }` class strings\n (`colorClasses(\"chart-3\").tint` is `bg-chart-3/30`). `Palette`,\n `StickySwatch`, `ColorDot` and `ShapeGlyph` are the canvas's own pickers\n and swatches, for a legend or a side panel.\n- An interactive kit part inside a node takes `className=\"nodrag\"`.\n- Put a node's actions in `toolbarExtra` or `contextMenuItems`, not in a\n kit `Menu`/`Popover` inside the node: the node lives in the zoomed and\n panned viewport (a CSS transform), and the kit's floating panels anchor\n to their button without knowing about it. The canvas's own toolbars sit\n outside the viewport for this reason.\n\n## Rail tools\n\n```js\nconst TOOLS = [\n // A placing tool: pick it (or press P), click the canvas, a node appears there.\n { id: \"person\", label: \"Person\", icon: \"user\", shortcut: \"P\",\n create: (point, { rect, api }) => createNode(\"person\", point, { width: 256, data: { name: \"\", role: \"user\" } }) },\n // An action: pressing it runs onSelect — open your picker, then add.\n { id: \"pick\", label: \"Add from CRM\", icon: \"search\",\n onSelect: (api) => openPicker((p) => api.addNodes(createNode(\"person\", api.centerPoint(), { data: p }))) },\n];\n```\n\n- `create(point, { rect, api, grid })` returns a node or several (use\n `createNode`: it centres on `point` and snaps). With `sizable: true` the\n person can drag out a rectangle and you get it as `rect`.\n- A tool with `create` is also offered in the menu that opens when a\n connector is dropped on empty canvas (the new node arrives connected);\n `connectable: false` leaves it out.\n- The tool returns to Select after one placement. `shortcut` is one key.\n\n## Constraining behaviour — recipes\n\n- **Snap stickies into cells** (journey map): compute the cells from your\n lanes, then\n `constrain={(n) => { if (n.type !== \"sticky\") return; const r = nodeRect(n); const c = snapToCell(r, cells); return c && { position: { x: c.x, y: c.y }, data: { ...n.data, cell: c.cellId } }; }}`.\n- **Lock the lanes**: lanes are `frame` nodes with `draggable: false,\n deletable: false, selectable: false` (and `data.locked: true` to hide the\n resize handles). They still hold what is dropped inside them.\n- **Only steps connect** (process map): `canConnect={(c, b) => isStep(c.source) && isStep(c.target)}`.\n- **At most N votes** (idea board): keep votes in `data.badge` (the sticky\n shows it) and refuse extra ones in your own handler; or\n `beforeChange={(next) => votesOk(next) || false}`.\n- **Tidy a flow**: `api.autoLayout({ direction: \"LR\" })`, or in data\n `autoLayout(nodes, edges, { direction: \"TB\" })` before passing `value`.\n\n## The helpers\n\nImport them from the block; they take a board and never throw on a partial\none.\n\n- `emptyBoard()` — `{ nodes: [], edges: [] }`.\n- `createNode(type, point, { id?, data?, width?, height?, rect?, grid = 20, … })`\n — a node centred on `point` with its type's defaults, snapped. Extra keys\n (`draggable`, `zIndex`…) go on the node.\n- `createEdge(source, target, { id?, label?, data? })`.\n- `cleanBoard(board)` — what is stored (React Flow's working fields gone).\n- `boardToText(board, { describe? })` — plain text for an agent or a screen\n reader: `Board: 12 items, 5 connectors.`, then each frame with what it\n holds (reading order, nested), loose items, and `Connectors:` as\n `\"A\" → \"B\" (label)`.\n- `boardToMarkdown(board, { title?, describe? })` — a heading per frame, a\n bullet per item, a Connections list.\n- `autoLayout(nodes, edges, { direction = \"LR\", rankGap = 80, nodeGap = 40, ids?, grid = 20 })`\n — a layered layout written here (no dagre/elk): cycles broken, ranks by\n longest path, barycentre sweeps against crossings; unconnected nodes in\n a row after; the top-left corner kept. Returns every node, the laid-out\n ones moved. Frames are left alone unless named in `ids`.\n- `alignNodes(nodes, ids, how)`, `distributeNodes(nodes, ids, axis)`,\n `duplicateNodes(board, ids, { offset })` → `{ nodes, edges }`,\n `removeNodes(board, ids)`.\n- `frameContents(board, frameId)` → ids inside it; `frameOf(board)` →\n Map(nodeId → the smallest frame holding it).\n- `nodeRect(node)`, `boardBounds(nodes)`, `snapPoint(p, grid)`,\n `snapToCell(rect, cells, { pad })` → `{ x, y, cellId }`,\n `fitFontSize(text, width, height)` (the sticky's shrinking words).\n- `historyInit(board)`, `historyReducer(state, { type: \"push\" | \"undo\" |\n \"redo\" | \"reset\", present? })` — the undo stack the canvas uses, for a\n block that keeps its own.\n- `starterTemplates()`, `describeNode(node, describe?)`, `nodeText(node)`,\n `COLORS`, `STICKY_COLORS`, `COLOR_NAMES`, `SHAPES`, `GRID`.\n\n## Mouse and keyboard\n\n- Scroll or Space + drag pans; pinch or Ctrl/⌘ + scroll zooms; the middle\n button drags the canvas in any tool.\n- Drag on empty canvas draws a selection box (what it fully encloses);\n Shift-click adds to the selection; drag a frame by its title.\n- Tools: V select, H hand, N sticky, S shape, T text, L connector, F frame,\n and your tools' keys. Shapes, text and frames can be dragged out to a size.\n- Enter edits the selected card's words, Esc stops; Delete/Backspace\n deletes; ⌘D duplicates; ⌘C/⌘X/⌘V copy, cut, paste (paste lines of text\n from elsewhere: a sticky each); ⌘A selects all; ⌘Z / ⇧⌘Z undo, redo;\n arrows nudge 1 px (⇧ 10); ⌘] / ⌘[ front, back; ⇧⌘L lock; ⇧1 fit;\n ⇧2 zoom to selection; ⌘+ / ⌘− / ⌘0 zoom; ? the shortcuts sheet.\n- Tab walks the cards (each selected as it gets focus); Shift+F10 or the\n Menu key opens the actions menu; the List view (zoom bar) shows the board\n as a list, and choosing a row goes back to that card.\n\n## Example\n\n```jsx\n// components/LaunchBoard.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { FlowCanvas } from \"./blocks/flow-canvas\";\n\nexport default function LaunchBoard({ project }) {\n const [board, setBoard] = useState(project.board); // jsonb, may be null\n return (\n <FlowCanvas\n value={board ?? undefined}\n onChange={setBoard}\n onSave={(b) => updateRow({ source: \"public.projects\", id: project.id, patch: { board: b } })}\n label={`${project.name} board`}\n height=\"calc(100dvh - 8rem)\"\n />\n );\n}\n```\n\nA card drawn with kit parts, and an agent reading the board:\n\n```jsx\n<FlowCanvas\n value={board}\n onChange={setBoard}\n renderCard={(n) => n.data.kind === \"deal\" ? (\n <div className=\"grid gap-1 p-3\">\n <p className=\"text-sm font-medium\">{n.data.title}</p>\n <Stat size=\"sm\" label=\"Value\" value={n.data.amount} />\n </div>\n ) : undefined}\n/>\n\nconst context = boardToText(board); // hand this to the agent\n```\n\n## What it will not do\n\n- Collaborate live (several cursors at once). Saves are last-write-wins.\n- Draw freehand (that is `sketch-canvas`), or export an image.\n- Nest data under frames: membership is geometric, so a card dragged out\n of a frame simply leaves it.\n- Route connectors around cards; they take the shortest curve between the\n facing sides.\n- Upload images itself: pass `pickImage`.\n- Render on the server: the server shows the board as text until the\n canvas mounts. For a read-only page that must be rich in the HTML, render\n `boardToMarkdown` yourself.\n\nA board it cannot be is your own component in `components/` — not an edit\nto this block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add flow-canvas` restores them.\n\n## Credits\n\nReact Flow (https://github.com/xyflow/xyflow), MIT, by webkid GmbH —\ninstalled from npm as a dependency, not copied here. Its attribution link\nstays in the corner unless `hideAttribution`. See THIRD-PARTY-NOTICES.md.\n"
|
|
66
|
+
"content": "# FlowCanvas\n\nA Miro-like board in the workspace theme. Sticky notes, shapes, text,\nframes, cards and images on an infinite dotted canvas. A tool rail on the\nleft (a bar along the bottom on a narrow canvas), double-click anywhere\nfor a sticky, drag from a card's dot to another card to connect them —\nor drop on empty canvas to pick what to add there. A floating toolbar\nover the selection, a right-click menu, undo/redo, copy/paste, snap to\ngrid, a minimap, a list view for keyboards and screen readers, and a\npresentation mode that steps through the frames.\n\nIt is [React Flow](https://reactflow.dev) (`@xyflow/react`, MIT)\nunderneath, with its colours mapped onto the workspace's tokens. The\ncanvas blocks — `account-map`, `process-map`, `journey-map`, `idea-board`\n— are built on this one: they import it, never `@xyflow/react`.\n\n```jsx\nimport { FlowCanvas, boardToText } from \"../components/blocks/flow-canvas\"; // a Makersclaw app (\"./blocks/flow-canvas\" from components/)\nimport { FlowCanvas, boardToText } from \"@/components/makersclaw/blocks/flow-canvas\"; // a shadcn project\n```\n\n`FlowCanvas` is a client component: render it from a `\"use client\"`\ncomponent. The helpers (`boardToText`, `autoLayout`, `createNode`…) are\nplain functions and work on the server too.\n\n`makersclaw add flow-canvas` adds `@xyflow/react` (pinned, exact) to\npackage.json: run `npm install` after. It loads with the page (no\n`import()`): the board mounts after the first render; until then — on the\nserver — the box holds the board as text for a screen reader.\n\n## The data\n\nA board is plain JSON. Store it as it comes out of `onChange` (one `jsonb`\ncolumn).\n\n```js\n{\n nodes: [\n { id: \"s1\", type: \"sticky\", position: { x: 40, y: 80 }, width: 200, height: 200,\n data: { text: \"Interview churned customers\", color: \"warning\", badge: 3, meta: \"Priya\" } },\n { id: \"f1\", type: \"frame\", position: { x: 0, y: 0 }, width: 720, height: 420, zIndex: -1,\n data: { title: \"Discover\", color: \"chart-2\" } },\n ],\n edges: [\n { id: \"e1\", source: \"s1\", target: \"s2\", label: \"feeds\",\n data: { line: \"curved\", arrow: \"end\", dashed: false } },\n ],\n viewport: { x: 0, y: 0, zoom: 1 }, // where the person last looked; optional\n}\n```\n\n- **Positions are absolute**, top-left corner, in board pixels. A card in a\n frame is not stored relative to it: a frame holds whatever lies inside\n its rectangle (`frameContents`, `frameOf`). Dragging a frame moves what\n is inside it.\n- **Colours are token names**, never colour values, so a board repaints\n with the theme: `\"warning\"` (the yellow sticky), `\"chart-1\"` … `\"chart-5\"`,\n `\"success\"`, `\"info\"`, `\"destructive\"`, `\"muted\"`. `COLORS` lists them;\n `STICKY_COLORS` is the sticky palette.\n- **Paint order is list order** (later is on top); frames always sit\n under everything (`zIndex: -1`).\n- `width`/`height` are set by resizing; `text` and `card` nodes leave\n `height` out and grow with their content.\n- A `sticky` or `shape` always has a box: one that arrives without a\n `width` or `height` gets its type's default for the missing side\n (sticky 200×200; a shape its outline's, e.g. pill 180×64). Their words\n are fitted to that box, so give a starter board's stickies the size you\n want them to have (`withFittedSize` is what the canvas applies).\n- A node may carry React Flow's own flags: `draggable: false`,\n `deletable: false`, `selectable: false`, `connectable: false`. Locking\n (the toolbar's lock) sets `data.locked: true` with `draggable` and\n `deletable` false.\n\nThe built-in node types and their `data`:\n\n| type | data |\n| --- | --- |\n| `sticky` | `text`, `color` (default `\"warning\"`), `badge?` (a count in the corner: votes), `meta?` (a small line at the foot: an author) |\n| `shape` | `text`, `shape`: `rect` · `ellipse` · `diamond` · `pill`, `color` (default `\"muted\"`), `textSize`: `sm` · `md` · `lg` · `xl` |\n| `text` | `text`, `textSize`, `align?`: `left` · `center` · `right` |\n| `frame` | `title`, `color?` (a faint wash and edge; none = the card surface) |\n| `card` | `title`, `body?`, `icon?` (a kit icon name), `badge?`, `color?` (a strip on top), `kind?`, anything else your `renderCard` reads |\n| `image` | `src`, `alt` |\n\nConnectors: `data.line` `curved` (default) · `straight` · `elbow`;\n`data.arrow` `end` (default) · `both` · `none`; `data.dashed`. They leave\nand enter by the sides of the two cards that face each other, so they\nre-route as cards move; handle ids are not stored.\n\n## Props\n\n- `value` — the board, controlled. A new `value` that differs from what\n the canvas last sent through `onChange` replaces it (loading another\n board, or a change made elsewhere — a vote count); that replacement can\n be undone. Pass back exactly what `onChange` gave you and nothing resets.\n- `defaultValue` — the starting board, uncontrolled. With neither, it\n starts empty (and shows the empty state).\n- `onChange(board)` — after each committed change (a drag ends, a card is\n placed, words stop being edited, a colour is picked), debounced by\n `changeDelay` (default 400 ms). The board is clean JSON (see above).\n Selection, typing in progress and panning never call it.\n- `onSave(board)` — `async`, return `{ ok: true }` or `{ ok: false, error }`\n (a throw counts as failed). Called after `onChange`, and on ⌘S. The\n canvas shows \"Saving…\", \"Saved\", or the error with a Retry beside\n undo/redo. Leave it out when `onChange` already saves.\n- `changeDelay` — ms, default 400.\n- `readOnly` — `false` (default). `true`: no rail, no editing, no\n connecting; pan, zoom, fit, the list view and `onOpenNode` still work.\n- `presentation` — `false` (default). `true`: read-only, and a bar steps\n through the frames in reading order (← → or the buttons; each fills the\n screen). `onExitPresentation()` — Esc and the bar's Exit call it; turn\n `presentation` off there.\n- `height` — the canvas's height, default `560` (px; any CSS length works:\n `\"70vh\"`, `\"100%\"` inside a sized box).\n- `bare` — `false` (default: rounded, bordered). `true`: no border or\n radius, for a full-page board.\n- `label` — the board's accessible name, default `\"Board\"`; also the list\n view's heading.\n- `className` — layout around it (margin, width). Never colour.\n- `palette` — the sticky colours offered, token names, default\n `STICKY_COLORS` (six). The toolbar adds a few more for shapes and frames.\n- `snap` — `true` (default): positions and sizes snap to `grid` (default\n `20` px — the dot spacing). The person can turn it off from the view menu.\n- `minimap` — `false` (default): whether the minimap starts open (a toggle\n in the zoom bar either way; hidden on a narrow canvas).\n- `hideAttribution` — `false` (default). React Flow asks that its small\n credit stays; its MIT licence allows hiding it.\n- `canvasRef` — a ref (`useFlowCanvas()`) that receives the handle (below).\n- `onSelectionChange(nodes, edges)` — the selection changed (a side panel\n for the selected person).\n- `onOpenNode(node)` — double-click or Enter on a node that has no words of\n its own to edit: your own node types, cards drawn by `renderCard`, and\n every node in read-only mode. Open your `Sheet` there. Without it,\n double-click edits the words.\n- `describeNode(node)` — a one-line description of one of YOUR node types,\n for the list view, `boardToText` and the server placeholder; return\n nothing for the default (`Sticky: \"…\"`).\n\nExtending it — how a block makes the canvas its own:\n\n- `nodeTypes` — `{ person: PersonNode, … }`: your own node components,\n added to the built-in ones (a key that matches a built-in replaces it).\n See \"Your own node types\".\n- `renderCard(node, ctx)` — draws the inside of every `card` node; return\n `undefined` for the default (title + body). `ctx`: `{ selected, editing,\n update(patch), api }`. Kit parts only (Avatar, Badge, Stat…); give an\n interactive part inside `className=\"nodrag\"` so pressing it does not drag\n the card. The simplest way to put your own card on the board.\n- `tools` — more rail tools: `[{ id, label, icon, shortcut?, create?,\n onSelect?, sizable?, connectable? }]`. See \"Rail tools\".\n- `rail` — the rail's contents, in order: built-in ids (`\"select\"`,\n `\"hand\"`, `\"sticky\"`, `\"shape\"`, `\"text\"`, `\"connector\"`, `\"frame\"`,\n `\"image\"`), your tools' ids, and `\"|\"` for a divider. Default: all the\n built-ins, then your tools (and `\"image\"` when `pickImage` is set). A\n process map drops the sticky: `rail={[\"select\", \"hand\", \"|\", \"shape\", \"connector\", \"step\"]}`.\n- `pickImage()` — `async () => ({ src, alt, width?, height? }) | null`: turns\n on the image tool (your upload, your picker). Without it there is no\n image tool; image nodes in the data still show.\n- `topBar` — a node shown floating at the top centre: a title, a save\n button, a share menu, a timer.\n- `toolbarExtra(selection, api)` — more controls at the end of the\n floating toolbar over a selection (`selection.nodes`): kit\n `IconButton size=\"sm\"` / `Menu` parts. \"Vote\", \"Assign owner\".\n- `contextMenuItems(selection, api)` — more right-click items, kit `Menu`\n shape (`[{ label, onSelect, icon?, shortcut?, tone? }]`); `selection.nodes`\n is empty for a right-click on the empty canvas.\n- `emptyState` — a node in place of the first-run hint; `false` for none.\n- `templates` — the starters on the empty state: `[{ id, label, icon?,\n description?, board }]` (`board` a board or a function returning one).\n Default `starterTemplates()` (Brainstorm, Flowchart, Retro); `[]` for none.\n- `constrain(node, { reason, board })` — runs on every node a change\n touched before it is committed (`reason`: `\"move\"` after a drag or a\n nudge, `\"create\"`, `\"paste\"`, `\"resize\"`, `\"align\"`, `\"layout\"`). Return\n the node adjusted (a new position, size or data), or nothing to keep it.\n This is how a journey map snaps a sticky into its cell and records it.\n- `beforeChange(next, prev, reason)` — runs on the whole board at every\n commit (`next` and `prev` are clean boards). Return `false` to refuse the\n change (the board goes back), a board to commit instead, or nothing to\n accept it.\n- `canConnect(connection, board)` — `false` refuses a connector\n (`connection`: `{ source, target }`). A card never connects to itself.\n\n## The handle\n\n`const board = useFlowCanvas()`, `<FlowCanvas canvasRef={board} />`, then\n`board.current?.…`. Tools' `create`/`onSelect`, `renderCard`,\n`toolbarExtra` and `contextMenuItems` get the same object as `api`, and a\nnode component reaches it with `useCanvas().api`. Every change made through\nit commits (undoable, then `onChange`) unless `{ commit: false }` says not.\n\n- Read: `getBoard()` (clean JSON, with the viewport) · `getSelection()` →\n `{ nodes, edges }` · `toText()` · `toMarkdown({ title })` · `canUndo()` ·\n `canRedo()` · `getTool()` · `readOnly`\n- Nodes: `addNodes(node | nodes, { select = true, edit = false, edges })` →\n ids · `updateNode(id, patch | (node) => patch, { commit })` ·\n `updateNodeData(id, patch | (data) => patch, { commit })` ·\n `updateNodesData(ids, patch)` · `removeNodes(ids)` · `duplicate(ids)` ·\n `lock(ids, on)` · `arrange(ids, \"front\" | \"back\")` · `align(ids, \"left\" |\n \"center\" | \"right\" | \"top\" | \"middle\" | \"bottom\")` · `distribute(ids,\n \"horizontal\" | \"vertical\")` · `frameSelection(ids)`\n- Connectors: `addEdge({ source, target, label?, data? })` → id ·\n `updateEdge(id, patch, { commit })` · `removeEdges(ids)`\n- The whole board: `setBoard(board, { fit = true })` (undoable) ·\n `autoLayout({ direction: \"LR\" | \"TB\", ids?, rankGap?, nodeGap? })` ·\n `undo()` · `redo()` · `save()` (send now, skipping the debounce)\n- Editing: `edit(id)` (start editing a node's words) · `editEdge(id)` ·\n `stopEditing()` · `commit(reason, ids?)` (after `{ commit: false }` changes)\n- View: `select(ids, edgeIds?)` · `focusNode(id)` (select, centre, focus) ·\n `fitView({ nodes?: [{ id }] })` · `zoomIn()` · `zoomOut()` · `zoomTo(z)` ·\n `centerPoint()` (the board point at the middle of the screen — where a\n picker's choice lands) · `screenToFlow({ x, y })` · `setTool(id)`\n\n## Your own node types\n\nA node component gets React Flow's props: `{ id, type, data, selected,\nwidth, height, dragging }`. Wrap its body in `NodeShell` — it draws the\nselection box, the four connection dots (shown on hover), the resize\nhandles, the lock mark and the whole-card drop target while a connector is\ndragged. Without it (or `NodeHandles`), connectors cannot reach the node.\nRead the canvas with `useCanvas()`; change the node through `api`.\n\n```jsx\nimport { FlowCanvas, NodeShell, EditableText, useCanvas, createNode } from \"./blocks/flow-canvas\";\nimport { Avatar, Badge } from \"./ui\";\n\nfunction PersonNode({ id, data, selected }) {\n const { api, editingNode, readOnly } = useCanvas();\n return (\n <NodeShell id={id} selected={selected} locked={data.locked} resizable={false}>\n <div className=\"flex w-64 items-center gap-3 rounded-lg border border-border bg-card p-3 shadow-sm\">\n <Avatar name={data.name} src={data.avatar} />\n <div className=\"min-w-0 flex-1\">\n <EditableText\n singleLine\n editing={!readOnly && editingNode === id}\n value={data.name}\n onChange={(v) => api.updateNodeData(id, { name: v }, { commit: false })}\n onDone={() => api.stopEditing()}\n label=\"Name\"\n className=\"truncate text-sm font-medium\"\n />\n <p className=\"truncate text-xs text-muted-foreground\">{data.title}</p>\n </div>\n <Badge tone={data.role === \"blocker\" ? \"danger\" : \"neutral\"}>{data.role}</Badge>\n </div>\n </NodeShell>\n );\n}\nconst NODE_TYPES = { person: PersonNode }; // module level: a new object each render remounts every node\n\n<FlowCanvas nodeTypes={NODE_TYPES} describeNode={(n) => n.type === \"person\" ? `Person: ${n.data.name} (${n.data.role})` : undefined} … />\n```\n\n- `NodeShell` props: `id`, `selected`, `locked`, `resizable` (default\n true), `minWidth`/`minHeight` (40/30), `keepAspectRatio`, `autoHeight`\n (height follows content), `handles` (false: nothing connects), `ring`\n (false: draw your own selection), `className` (layout only).\n- `EditableText` props: `value`, `editing`, `onChange(text)`, `onDone()`,\n `placeholder`, `singleLine` (Enter finishes), `label`, `className`,\n `style`. Shown and edited in the same box, so nothing jumps.\n- Double-click and Enter call `onOpenNode(node)` for your types when you\n pass it, else they set `editingNode` to the node's id (use it as above).\n- Colour your node from a token in its data with `colorClasses(token)` →\n `{ tint, solid, fill, stroke, wash, edge }` class strings\n (`colorClasses(\"chart-3\").tint` is `bg-chart-3/30`). `Palette`,\n `StickySwatch`, `ColorDot` and `ShapeGlyph` are the canvas's own pickers\n and swatches, for a legend or a side panel.\n- An interactive kit part inside a node takes `className=\"nodrag\"`.\n- Put a node's actions in `toolbarExtra` or `contextMenuItems`, not in a\n kit `Menu`/`Popover` inside the node: the node lives in the zoomed and\n panned viewport (a CSS transform), and the kit's floating panels anchor\n to their button without knowing about it. The canvas's own toolbars sit\n outside the viewport for this reason.\n\n## Rail tools\n\n```js\nconst TOOLS = [\n // A placing tool: pick it (or press P), click the canvas, a node appears there.\n { id: \"person\", label: \"Person\", icon: \"user\", shortcut: \"P\",\n create: (point, { rect, api }) => createNode(\"person\", point, { width: 256, data: { name: \"\", role: \"user\" } }) },\n // An action: pressing it runs onSelect — open your picker, then add.\n { id: \"pick\", label: \"Add from CRM\", icon: \"search\",\n onSelect: (api) => openPicker((p) => api.addNodes(createNode(\"person\", api.centerPoint(), { data: p }))) },\n];\n```\n\n- `create(point, { rect, api, grid })` returns a node or several (use\n `createNode`: it centres on `point` and snaps). With `sizable: true` the\n person can drag out a rectangle and you get it as `rect`.\n- A tool with `create` is also offered in the menu that opens when a\n connector is dropped on empty canvas (the new node arrives connected);\n `connectable: false` leaves it out.\n- The tool returns to Select after one placement. `shortcut` is one key.\n\n## Constraining behaviour — recipes\n\n- **Snap stickies into cells** (journey map): compute the cells from your\n lanes, then\n `constrain={(n) => { if (n.type !== \"sticky\") return; const r = nodeRect(n); const c = snapToCell(r, cells); return c && { position: { x: c.x, y: c.y }, data: { ...n.data, cell: c.cellId } }; }}`.\n- **Lock the lanes**: lanes are `frame` nodes with `draggable: false,\n deletable: false, selectable: false` (and `data.locked: true` to hide the\n resize handles). They still hold what is dropped inside them.\n- **Only steps connect** (process map): `canConnect={(c, b) => isStep(c.source) && isStep(c.target)}`.\n- **At most N votes** (idea board): keep votes in `data.badge` (the sticky\n shows it) and refuse extra ones in your own handler; or\n `beforeChange={(next) => votesOk(next) || false}`.\n- **Tidy a flow**: `api.autoLayout({ direction: \"LR\" })`, or in data\n `autoLayout(nodes, edges, { direction: \"TB\" })` before passing `value`.\n\n## The helpers\n\nImport them from the block; they take a board and never throw on a partial\none.\n\n- `emptyBoard()` — `{ nodes: [], edges: [] }`.\n- `createNode(type, point, { id?, data?, width?, height?, rect?, grid = 20, … })`\n — a node centred on `point` with its type's defaults, snapped. Extra keys\n (`draggable`, `zIndex`…) go on the node.\n- `createEdge(source, target, { id?, label?, data? })`.\n- `cleanBoard(board)` — what is stored (React Flow's working fields gone).\n- `boardToText(board, { describe? })` — plain text for an agent or a screen\n reader: `Board: 12 items, 5 connectors.`, then each frame with what it\n holds (reading order, nested), loose items, and `Connectors:` as\n `\"A\" → \"B\" (label)`.\n- `boardToMarkdown(board, { title?, describe? })` — a heading per frame, a\n bullet per item, a Connections list.\n- `autoLayout(nodes, edges, { direction = \"LR\", rankGap = 80, nodeGap = 40, ids?, grid = 20 })`\n — a layered layout written here (no dagre/elk): cycles broken, ranks by\n longest path, barycentre sweeps against crossings; unconnected nodes in\n a row after; the top-left corner kept. Returns every node, the laid-out\n ones moved. Frames are left alone unless named in `ids`.\n- `alignNodes(nodes, ids, how)`, `distributeNodes(nodes, ids, axis)`,\n `duplicateNodes(board, ids, { offset })` → `{ nodes, edges }`,\n `removeNodes(board, ids)`.\n- `frameContents(board, frameId)` → ids inside it; `frameOf(board)` →\n Map(nodeId → the smallest frame holding it).\n- `nodeRect(node)`, `boardBounds(nodes)`, `snapPoint(p, grid)`,\n `snapToCell(rect, cells, { pad })` → `{ x, y, cellId }`,\n `fitFontSize(text, width, height)` (the sticky's shrinking words).\n- `historyInit(board)`, `historyReducer(state, { type: \"push\" | \"undo\" |\n \"redo\" | \"reset\", present? })` — the undo stack the canvas uses, for a\n block that keeps its own.\n- `starterTemplates()`, `describeNode(node, describe?)`, `nodeText(node)`,\n `COLORS`, `STICKY_COLORS`, `COLOR_NAMES`, `SHAPES`, `GRID`.\n\n## Mouse and keyboard\n\n- Scroll or Space + drag pans; pinch or Ctrl/⌘ + scroll zooms; the middle\n button drags the canvas in any tool.\n- Drag on empty canvas draws a selection box (what it fully encloses);\n Shift-click adds to the selection; drag a frame by its title.\n- Tools: V select, H hand, N sticky, S shape, T text, L connector, F frame,\n and your tools' keys. Shapes, text and frames can be dragged out to a size.\n- Enter edits the selected card's words, Esc stops; Delete/Backspace\n deletes; ⌘D duplicates; ⌘C/⌘X/⌘V copy, cut, paste (paste lines of text\n from elsewhere: a sticky each); ⌘A selects all; ⌘Z / ⇧⌘Z undo, redo;\n arrows nudge 1 px (⇧ 10); ⌘] / ⌘[ front, back; ⇧⌘L lock; ⇧1 fit;\n ⇧2 zoom to selection; ⌘+ / ⌘− / ⌘0 zoom; ? the shortcuts sheet.\n- Tab walks the cards (each selected as it gets focus); Shift+F10 or the\n Menu key opens the actions menu; the List view (zoom bar) shows the board\n as a list, and choosing a row goes back to that card.\n\n## Example\n\n```jsx\n// components/LaunchBoard.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { FlowCanvas } from \"./blocks/flow-canvas\";\n\nexport default function LaunchBoard({ project }) {\n const [board, setBoard] = useState(project.board); // jsonb, may be null\n return (\n <FlowCanvas\n value={board ?? undefined}\n onChange={setBoard}\n onSave={(b) => updateRow({ source: \"public.projects\", id: project.id, patch: { board: b } })}\n label={`${project.name} board`}\n height=\"calc(100dvh - 8rem)\"\n />\n );\n}\n```\n\nA card drawn with kit parts, and an agent reading the board:\n\n```jsx\n<FlowCanvas\n value={board}\n onChange={setBoard}\n renderCard={(n) => n.data.kind === \"deal\" ? (\n <div className=\"grid gap-1 p-3\">\n <p className=\"text-sm font-medium\">{n.data.title}</p>\n <Stat size=\"sm\" label=\"Value\" value={n.data.amount} />\n </div>\n ) : undefined}\n/>\n\nconst context = boardToText(board); // hand this to the agent\n```\n\n## What it will not do\n\n- Collaborate live (several cursors at once). Saves are last-write-wins.\n- Draw freehand (that is `sketch-canvas`), or export an image.\n- Nest data under frames: membership is geometric, so a card dragged out\n of a frame simply leaves it.\n- Route connectors around cards; they take the shortest curve between the\n facing sides.\n- Upload images itself: pass `pickImage`.\n- Render on the server: the server shows the board as text until the\n canvas mounts. For a read-only page that must be rich in the HTML, render\n `boardToMarkdown` yourself.\n\nA board it cannot be is your own component in `components/` — not an edit\nto this block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add flow-canvas` restores them.\n\n## Credits\n\nReact Flow (https://github.com/xyflow/xyflow), MIT, by webkid GmbH —\ninstalled from npm as a dependency, not copied here. Its attribution link\nstays in the corner unless `hideAttribution`. See THIRD-PARTY-NOTICES.md.\n"
|
|
67
67
|
}
|
|
68
68
|
],
|
|
69
69
|
"categories": [
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"docs": "Installed to components/makersclaw/blocks/flow-canvas. Import: import { FlowCanvas, useFlowCanvas, useCanvas, NodeShell, NodeHandles, EditableText, Palette, StickySwatch, ColorDot, ShapeGlyph, colorClasses, emptyBoard, createNode, createEdge, cleanBoard, boardToText, boardToMarkdown, autoLayout, snapToCell, frameContents, historyReducer, starterTemplates } from \"@/components/makersclaw/blocks/flow-canvas\". Props and an example: components/makersclaw/blocks/flow-canvas/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
87
87
|
"meta": {
|
|
88
88
|
"makersclaw": {
|
|
89
|
-
"version": "0.3.
|
|
89
|
+
"version": "0.3.2",
|
|
90
90
|
"useWhen": "A screen where people arrange things spatially and connect them: a brainstorm, a flowchart or process map, an account or org map, a journey map, a retro, a planning board. The canvas blocks (account-map, process-map, journey-map, idea-board) are built on it. Not for freehand drawing (sketch-canvas), records in stage columns (board), or a chart of numbers (chart)."
|
|
91
91
|
}
|
|
92
92
|
}
|