makersclaw 0.5.2 → 0.7.0

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.
Files changed (148) hide show
  1. package/README.md +73 -7
  2. package/package.json +3 -2
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +3 -3
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +3 -3
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +121 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +14 -5
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chat/README.md +43 -13
  67. package/registry/blocks/code-editor/README.md +4 -0
  68. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  69. package/registry/blocks/comment-box/README.md +20 -13
  70. package/registry/blocks/contact-section/README.md +7 -6
  71. package/registry/blocks/csv-import/README.md +34 -12
  72. package/registry/blocks/data-table/README.md +41 -8
  73. package/registry/blocks/diagram/diagram.jsx +1 -1
  74. package/registry/blocks/doc-editor/README.md +18 -6
  75. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  76. package/registry/blocks/doc-page/README.md +16 -6
  77. package/registry/blocks/file-upload/README.md +7 -1
  78. package/registry/blocks/flow-canvas/README.md +16 -5
  79. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  80. package/registry/blocks/footer/README.md +4 -2
  81. package/registry/blocks/gantt/README.md +18 -12
  82. package/registry/blocks/job-panel/README.md +1 -1
  83. package/registry/blocks/json-editor/README.md +16 -4
  84. package/registry/blocks/map/README.md +19 -3
  85. package/registry/blocks/map/map-view.jsx +1 -1
  86. package/registry/blocks/map/maplibre.js +1 -1
  87. package/registry/blocks/mind-map/README.md +16 -5
  88. package/registry/blocks/org-chart/README.md +10 -7
  89. package/registry/blocks/page-builder/README.md +13 -6
  90. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  91. package/registry/blocks/page-builder/page-view.jsx +1 -1
  92. package/registry/blocks/page-builder/page.js +1 -1
  93. package/registry/blocks/pdf-viewer/README.md +22 -20
  94. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  95. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  96. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  97. package/registry/blocks/product-detail/README.md +13 -7
  98. package/registry/blocks/product-detail/demo.jsx +1 -1
  99. package/registry/blocks/product-grid/README.md +12 -10
  100. package/registry/blocks/record-form/README.md +26 -9
  101. package/registry/blocks/record-notes/README.md +42 -30
  102. package/registry/blocks/search-dialog/README.md +38 -7
  103. package/registry/blocks/settings-list/README.md +29 -17
  104. package/registry/blocks/setup-checklist/README.md +12 -14
  105. package/registry/blocks/share-dialog/README.md +1 -1
  106. package/registry/blocks/signature-pad/README.md +15 -4
  107. package/registry/blocks/sketch-canvas/README.md +13 -4
  108. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  109. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  110. package/registry/blocks/spreadsheet/README.md +17 -8
  111. package/registry/blocks/stat-cards/README.md +17 -8
  112. package/registry/blocks/task-list/README.md +14 -11
  113. package/registry/blocks/task-model/README.md +3 -3
  114. package/registry/blocks/transaction-list/README.md +15 -7
  115. package/registry/blocks/whiteboard/README.md +19 -13
  116. package/src/cli.mjs +65 -9
  117. package/src/install.mjs +360 -21
  118. package/src/lockfile.mjs +468 -0
  119. package/src/pm.mjs +473 -0
  120. package/src/registry.mjs +125 -1
  121. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  122. package/templates/next/browser/app/layout.jsx +94 -0
  123. package/templates/next/browser/app/page.jsx +60 -0
  124. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  125. package/templates/next/browser/lib/actions.js +16 -0
  126. package/templates/next/browser/lib/data.js +43 -0
  127. package/templates/next/files/README.md +818 -82
  128. package/templates/next/files/app/claw-alive/route.js +2 -2
  129. package/templates/next/files/app/frame.js +134 -9
  130. package/templates/next/files/app/layout.jsx +2 -2
  131. package/templates/next/files/check/run.mjs +1491 -53
  132. package/templates/next/files/components/ui/item.jsx +2 -2
  133. package/templates/next/files/components/ui/notice.jsx +1 -1
  134. package/templates/next/files/components/ui/separator.jsx +1 -1
  135. package/templates/next/files/lib/actions.js +171 -52
  136. package/templates/next/files/lib/brain-server.js +96 -0
  137. package/templates/next/files/lib/brain.js +413 -0
  138. package/templates/next/files/lib/claw.js +11 -0
  139. package/templates/next/files/lib/data.js +120 -30
  140. package/templates/next/files/lib/db.js +97 -17
  141. package/templates/next/files/lib/door.js +680 -0
  142. package/templates/next/files/next.config.mjs +85 -9
  143. package/templates/next/files/package.json +3 -3
  144. package/templates/next/files/{middleware.js → proxy.js} +13 -6
  145. package/templates/next/files/surfaces.js +3 -2
  146. package/templates/next/pnpm-lock.yaml +5870 -0
  147. package/templates/next/template.json +13 -6
  148. package/templates/next/files/schema.sql +0 -22
@@ -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.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "@xyflow/react@12.11.6"
@@ -21,7 +21,7 @@
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 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"
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 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",
@@ -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 `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"
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// surfaces/launch.jsx — client: one project's board\n\"use client\";\nimport { useState } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { FlowCanvas } from \"../components/blocks/flow-canvas\";\n\nexport default function LaunchSurface() {\n const id = useSearchParams().get(\"row\");\n const { row: project, loading } = useRow({ source: \"projects\", id });\n if (loading || !project) return null;\n return <LaunchBoard key={project.id} project={project} />;\n}\n\nfunction LaunchBoard({ project }) {\n const [board, setBoard] = useState(project.board); // jsonb, may be null; read once per project\n return (\n <FlowCanvas\n value={board ?? undefined}\n onChange={setBoard}\n onSave={(b) => updateRow({ source: \"projects\", id: project.id, patch: { board: b } })}\n label={`${project.name} board`}\n height=\"calc(100dvh - 8rem)\"\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\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.5.2",
89
+ "version": "0.7.0",
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
  }
package/r/footer.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Footer",
6
6
  "description": "A public site's footer: brand and tagline, link columns or one row of links, social buttons in the kit's brand marks (X, LinkedIn, GitHub, YouTube, Instagram, Facebook), and the legal line with its links; optionally a newsletter sign-up with consent (validates, saves through an async onSubscribe returning { ok }, shows the failure), a call to action, a large brand statement, language and currency selects, and a version badge. Simple, columns or centred.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -25,7 +25,7 @@
25
25
  "path": "registry/blocks/footer/README.md",
26
26
  "type": "registry:file",
27
27
  "target": "@components/makersclaw/blocks/footer/README.md",
28
- "content": "# Footer\n\nA public site's footer. It holds the brand and a line about it, link\ncolumns (or one row of links), social buttons drawn with the kit's brand\nmarks, and the legal line with its links. Optional parts: a newsletter\nsign-up (with a consent box), a call to action, a large brand statement\nor quote, language and currency selects, and a version badge.\n\nThree layouts:\n- **columns** (default) — the brand, tagline and socials beside the link\n columns (and an inline sign-up), then the legal row;\n- **simple** — one row: brand, links, socials; then the legal row;\n- **centred** — everything stacked in the middle.\n\nThe sign-up checks the email (and the consent, when asked) in place,\nthen calls `newsletter.onSubscribe(email, { consent })`, which is async\nand returns `{ ok }`. While it runs the button spins. `{ ok: false,\nerror }` (or a throw) shows the error and keeps the email. `{ ok: true }`\nshows \"You're subscribed\". It is a full-width section: it pads itself\nand centres its content.\n\n```jsx\nimport { Footer } from \"../components/blocks/footer\"; // a Makersclaw app (\"./blocks/footer\" from components/)\nimport { Footer } from \"@/components/makersclaw/blocks/footer\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component (a\nlayout is the usual place).\n\n## The data\n\n```js\n// a link, anywhere: columns, links, legalLinks\n{ label: \"Pricing\", href: \"/pricing\", external?: true } // external opens a new tab\n\n// columns\n[{ title: \"Product\", links: [link, …] }, …] // up to 5 across; 2 on a phone\n\n// socials\n[{ network: \"x\", href: \"https://x.com/fieldline\", label? }, …]\n```\n\n`network` is one of `x`, `linkedin`, `github`, `youtube`, `instagram`,\n`facebook`, `google`, `apple`, `microsoft`, `email`, `rss`. Anything\nelse needs an `icon` (a kit Icon name) and a `label`. Each is an icon\nbutton, named to a screen reader (\"X\", \"LinkedIn\"), opening a new tab.\n\n## Props\n\n- `layout` — `columns` (default) · `simple` · `centered`.\n- `brand` — a node: your logo, linked home if you like.\n- `tagline` — a line under the brand (columns and centred).\n- `columns` — link columns (columns layout).\n- `links` — one row of links (simple and centred; under the socials in\n columns).\n- `socials` — as above.\n- `newsletter` — `{ onSubscribe, title?, body?, label?, placeholder?,\n consent?, look?, doneTitle?, doneBody? }`. `onSubscribe(email, {\n consent })` is async and returns `{ ok: true }` or `{ ok: false,\n error? }`; without it there is no sign-up. `consent` is the checkbox's\n label (a string or a node with a link), and must be ticked. `look`:\n `inline` (default: a column beside the links) · `card` (a full-width\n card under them). `label` is the button, default \"Subscribe\".\n- `cta` — `{ title?, body?, actions: [{ label, href?, onClick?, icon?,\n variant? }] }`: \"Sign in\", \"Get the app\". A band above the legal row;\n the first action is primary unless it says otherwise.\n- `statement` — a large line at the top: a string, or `{ text, by }` for\n a quote.\n- `locales` — `[{ id, label, options: [{ value, label }], value?,\n defaultValue?, onChange? }]`: language, currency, country selects in\n the legal row. Uncontrolled unless you pass `value`.\n- `version` — \"v4.2.0\": a code badge in the legal row.\n- `legal` — the copyright line: \"© 2026 Fieldline Ltd.\". Pass the year;\n the block does not read the clock.\n- `legalLinks` — privacy, terms, cookies.\n- `onNavigate(link, event)` — called when any link or a `cta` action with\n an `href` is followed, before navigation; `event.preventDefault()`\n stops it.\n- `label` — what a screen reader calls the footer and its link row.\n Default \"Site\".\n- `className` — the footer's spacing. Never colour.\n\n## Example\n\n```jsx\n// components/SiteFooter.jsx\n\"use client\";\nimport { Icon } from \"./ui\";\nimport { Footer } from \"./blocks/footer\";\nimport { subscribe } from \"../lib/actions\"; // your server action\n\nexport default function SiteFooter() {\n return (\n <Footer\n brand={<span className=\"inline-flex items-center gap-2\"><Icon name=\"zap\" /> Fieldline</span>}\n tagline=\"The pipeline small sales teams actually keep up to date.\"\n columns={[\n { title: \"Product\", links: [{ label: \"Pricing\", href: \"/pricing\" }, { label: \"Changelog\", href: \"/changelog\" }] },\n { title: \"Company\", links: [{ label: \"About\", href: \"/about\" }, { label: \"Careers\", href: \"/careers\" }] },\n ]}\n socials={[{ network: \"x\", href: \"https://x.com/fieldline\" }, { network: \"linkedin\", href: \"https://linkedin.com/company/fieldline\" }]}\n newsletter={{\n body: \"One email a month.\",\n consent: \"I agree to the privacy policy.\",\n onSubscribe: async (email) => subscribe(email),\n }}\n legal=\"© 2026 Fieldline Ltd. All rights reserved.\"\n legalLinks={[{ label: \"Privacy\", href: \"/privacy\" }, { label: \"Terms\", href: \"/terms\" }]}\n />\n );\n}\n```\n\n## What it will not do\n\n- Change the language or currency by itself: `locales[].onChange` is\n yours.\n- Stick to the bottom of a short page: that is your layout's\n `min-h-dvh` flex column.\n- Paint itself in the primary colour: the look is the workspace\n theme's.\n- Draw store badges: \"Get the app\" is two buttons with the Apple and\n Google marks.\n\nA footer it cannot be is your own component in `components/`, built\nfrom the kit — not an edit to this block. Its files are fixed: `npm run\ncheck` refuses a change, and `makersclaw add footer` restores them.\n\n## Credits\n\nLayouts ported from Creative Tim UI's footer blocks\n(https://github.com/creativetimofficial/ui, MIT), rewritten on the\nMakersclaw kit.\n"
28
+ "content": "# Footer\n\nA public site's footer. It holds the brand and a line about it, link\ncolumns (or one row of links), social buttons drawn with the kit's brand\nmarks, and the legal line with its links. Optional parts: a newsletter\nsign-up (with a consent box), a call to action, a large brand statement\nor quote, language and currency selects, and a version badge.\n\nThree layouts:\n- **columns** (default) — the brand, tagline and socials beside the link\n columns (and an inline sign-up), then the legal row;\n- **simple** — one row: brand, links, socials; then the legal row;\n- **centred** — everything stacked in the middle.\n\nThe sign-up checks the email (and the consent, when asked) in place,\nthen calls `newsletter.onSubscribe(email, { consent })`, which is async\nand returns `{ ok }`. While it runs the button spins. `{ ok: false,\nerror }` (or a throw) shows the error and keeps the email. `{ ok: true }`\nshows \"You're subscribed\". It is a full-width section: it pads itself\nand centres its content.\n\n```jsx\nimport { Footer } from \"../components/blocks/footer\"; // a Makersclaw app (\"./blocks/footer\" from components/)\nimport { Footer } from \"@/components/makersclaw/blocks/footer\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component (a\nlayout is the usual place).\n\n## The data\n\n```js\n// a link, anywhere: columns, links, legalLinks\n{ label: \"Pricing\", href: \"/pricing\", external?: true } // external opens a new tab\n\n// columns\n[{ title: \"Product\", links: [link, …] }, …] // up to 5 across; 2 on a phone\n\n// socials\n[{ network: \"x\", href: \"https://x.com/fieldline\", label? }, …]\n```\n\n`network` is one of `x`, `linkedin`, `github`, `youtube`, `instagram`,\n`facebook`, `google`, `apple`, `microsoft`, `email`, `rss`. Anything\nelse needs an `icon` (a kit Icon name) and a `label`. Each is an icon\nbutton, named to a screen reader (\"X\", \"LinkedIn\"), opening a new tab.\n\n## Props\n\n- `layout` — `columns` (default) · `simple` · `centered`.\n- `brand` — a node: your logo, linked home if you like.\n- `tagline` — a line under the brand (columns and centred).\n- `columns` — link columns (columns layout).\n- `links` — one row of links (simple and centred; under the socials in\n columns).\n- `socials` — as above.\n- `newsletter` — `{ onSubscribe, title?, body?, label?, placeholder?,\n consent?, look?, doneTitle?, doneBody? }`. `onSubscribe(email, {\n consent })` is async and returns `{ ok: true }` or `{ ok: false,\n error? }`; without it there is no sign-up. `consent` is the checkbox's\n label (a string or a node with a link), and must be ticked. `look`:\n `inline` (default: a column beside the links) · `card` (a full-width\n card under them). `label` is the button, default \"Subscribe\".\n- `cta` — `{ title?, body?, actions: [{ label, href?, onClick?, icon?,\n variant? }] }`: \"Sign in\", \"Get the app\". A band above the legal row;\n the first action is primary unless it says otherwise.\n- `statement` — a large line at the top: a string, or `{ text, by }` for\n a quote.\n- `locales` — `[{ id, label, options: [{ value, label }], value?,\n defaultValue?, onChange? }]`: language, currency, country selects in\n the legal row. Uncontrolled unless you pass `value`.\n- `version` — \"v4.2.0\": a code badge in the legal row.\n- `legal` — the copyright line: \"© 2026 Fieldline Ltd.\". Pass the year;\n the block does not read the clock.\n- `legalLinks` — privacy, terms, cookies.\n- `onNavigate(link, event)` — called when any link or a `cta` action with\n an `href` is followed, before navigation; `event.preventDefault()`\n stops it.\n- `label` — what a screen reader calls the footer and its link row.\n Default \"Site\".\n- `className` — the footer's spacing. Never colour.\n\n## Example\n\n```jsx\n// components/SiteFooter.jsx\n\"use client\";\nimport { Icon } from \"./ui\";\nimport { Footer } from \"./blocks/footer\";\nimport { insertRow } from \"../lib/actions\";\n\nexport default function SiteFooter() {\n return (\n <Footer\n brand={<span className=\"inline-flex items-center gap-2\"><Icon name=\"zap\" /> Fieldline</span>}\n tagline=\"The pipeline small sales teams actually keep up to date.\"\n columns={[\n { title: \"Product\", links: [{ label: \"Pricing\", href: \"/pricing\" }, { label: \"Changelog\", href: \"/changelog\" }] },\n { title: \"Company\", links: [{ label: \"About\", href: \"/about\" }, { label: \"Careers\", href: \"/careers\" }] },\n ]}\n socials={[{ network: \"x\", href: \"https://x.com/fieldline\" }, { network: \"linkedin\", href: \"https://linkedin.com/company/fieldline\" }]}\n newsletter={{\n body: \"One email a month.\",\n consent: \"I agree to the privacy policy.\",\n onSubscribe: (email) => insertRow({ source: \"subscribers\", values: { email } }), // { ok, id } or { ok: false, error }\n }}\n legal=\"© 2026 Fieldline Ltd. All rights reserved.\"\n legalLinks={[{ label: \"Privacy\", href: \"/privacy\" }, { label: \"Terms\", href: \"/terms\" }]}\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Change the language or currency by itself: `locales[].onChange` is\n yours.\n- Stick to the bottom of a short page: that is your layout's\n `min-h-dvh` flex column.\n- Paint itself in the primary colour: the look is the workspace\n theme's.\n- Draw store badges: \"Get the app\" is two buttons with the Apple and\n Google marks.\n\nA footer it cannot be is your own component in `components/`, built\nfrom the kit — not an edit to this block. Its files are fixed: `npm run\ncheck` refuses a change, and `makersclaw add footer` restores them.\n\n## Credits\n\nLayouts ported from Creative Tim UI's footer blocks\n(https://github.com/creativetimofficial/ui, MIT), rewritten on the\nMakersclaw kit.\n"
29
29
  }
30
30
  ],
31
31
  "categories": [
@@ -45,7 +45,7 @@
45
45
  "docs": "Installed to components/makersclaw/blocks/footer. Import: import { Footer } from \"@/components/makersclaw/blocks/footer\". Props and an example: components/makersclaw/blocks/footer/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
46
46
  "meta": {
47
47
  "makersclaw": {
48
- "version": "0.5.2",
48
+ "version": "0.7.0",
49
49
  "useWhen": "The bottom of every page of a public site: a marketing site, a docs hub, a shop, a customer portal. Not for an app's own status bar or a toolbar pinned to the bottom of a screen."
50
50
  }
51
51
  }