makersclaw 0.4.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +4 -4
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +9 -9
- package/r/cart.json +2 -2
- package/r/chart.json +7 -7
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +5 -5
- package/r/contact-section.json +2 -2
- package/r/core.json +5 -5
- package/r/csv-import.json +3 -3
- package/r/data-table.json +4 -4
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +4 -4
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +6 -6
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +6 -6
- package/r/registry.json +121 -121
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +4 -4
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +3 -3
- package/registry/blocks/approval-request/README.md +2 -1
- package/registry/blocks/approval-request/approval-request.jsx +5 -3
- package/registry/blocks/calendar-view/README.md +8 -0
- package/registry/blocks/calendar-view/agenda.jsx +5 -4
- package/registry/blocks/calendar-view/calendar.jsx +23 -8
- package/registry/blocks/calendar-view/demo.jsx +2 -0
- package/registry/blocks/calendar-view/month.jsx +6 -5
- package/registry/blocks/calendar-view/time-grid.jsx +9 -7
- package/registry/blocks/calendar-view/tray.jsx +5 -4
- package/registry/blocks/chart/README.md +9 -4
- package/registry/blocks/chart/cartesian.jsx +31 -24
- package/registry/blocks/chart/core.jsx +21 -0
- package/registry/blocks/chart/pie.jsx +15 -10
- package/registry/blocks/chart/sparkline.jsx +32 -14
- package/registry/blocks/chat/message.jsx +4 -4
- package/registry/blocks/comment-box/README.md +1 -1
- package/registry/blocks/comment-box/comment-time.jsx +4 -6
- package/registry/blocks/data-table/README.md +4 -0
- package/registry/blocks/data-table/data-table.jsx +20 -6
- package/registry/blocks/flow-canvas/edges.jsx +4 -2
- package/registry/blocks/flow-canvas/theme.js +59 -48
- package/registry/blocks/mind-map/README.md +9 -2
- package/registry/blocks/mind-map/mind-map.jsx +51 -12
- package/registry/blocks/mind-map/tree.js +33 -0
- package/registry/blocks/org-chart/org-chart.jsx +1 -1
- package/registry/blocks/pdf-viewer/notes.jsx +4 -3
- package/registry/blocks/record-notes/README.md +1 -1
- package/registry/blocks/record-notes/record-notes.jsx +4 -5
- package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
- package/registry/blocks/sidebar/sidebar.jsx +13 -13
- package/registry/blocks/signature-pad/signature.js +8 -1
- package/registry/blocks/spreadsheet/cells.jsx +2 -2
- package/templates/next/files/README.md +77 -19
- package/templates/next/files/app/auto-refresh.jsx +39 -74
- package/templates/next/files/app/frame.js +197 -6
- package/templates/next/files/check/run.mjs +96 -11
- package/templates/next/files/components/ui/calendar.jsx +24 -0
- package/templates/next/files/components/ui/field.jsx +12 -3
- package/templates/next/files/components/ui/index.js +1 -0
- package/templates/next/files/components/ui/sheet.jsx +38 -2
- package/templates/next/files/components/ui/toggle.jsx +20 -2
- package/templates/next/files/lib/actions.js +63 -5
- package/templates/next/files/lib/data.js +4 -1
- package/templates/next/files/lib/db.js +18 -1
- package/templates/next/files/lib/hooks.js +52 -17
- package/templates/next/template.json +2 -2
package/r/mind-map.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"title": "MindMap",
|
|
6
6
|
"description": "A mind map you can build without the mouse, on flow-canvas: a centre topic with main branches balanced on both sides, each branch a tidy auto-laid-out tree in its own chart hue. Tab adds a subtopic, Enter the next topic, typing renames, arrows walk the map, ⌘↑/⌘↓ fold a branch (a folded one shows how many it hides), ⌥↑/⌥↓ reorder, Shift+Tab moves up a level; drag a topic onto another to move its branch. Undo/redo, a colour per branch, an Outline view, copy a branch as an outline and paste an outline as topics, starter templates. The map is plain JSON (a tree of topics); saves are async callbacks answering { ok }.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/flow-canvas.json"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [],
|
|
12
12
|
"files": [
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"path": "registry/blocks/mind-map/mind-map.jsx",
|
|
21
21
|
"type": "registry:component",
|
|
22
22
|
"target": "@components/makersclaw/blocks/mind-map/mind-map.jsx",
|
|
23
|
-
"content": "\"use client\";\n\n// MindMap — a mind map you can drive from the keyboard alone, on\n// FlowCanvas. The centre topic, main branches balanced on both sides, each\n// branch a tidy tree in its own chart hue. Tab adds a subtopic, Enter the\n// next topic, typing renames, arrows walk the map, ⌘↑/⌘↓ fold; drag a\n// topic onto another to move its branch there.\n//\n// The map is a tree of topics (tree.js), and this component owns it: every\n// change makes a new tree, pushes the old one on the undo stack, lays the\n// tree out again and hands the canvas the result as its `value`. The\n// canvas runs read-only (its own tools, stickies and undo never see this\n// map); pointer and keys come through this component instead, and the\n// canvas pans, zooms, fits and draws.\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, Icon, IconButton, Kbd, Menu, Popover, Separator, Sheet, ToggleGroup, Tooltip, cx, toast } from \"../../ui\";\nimport { ColorDot, FlowCanvas, Palette, useFlowCanvas } from \"../flow-canvas\";\nimport { MindContext, NODE_TYPES } from \"./topic\";\nimport {\n BRANCH_COLORS,\n countTopics,\n createTopic,\n duplicateTopic,\n estimateText,\n findTopic,\n indentTopic,\n insertSibling,\n insertTopic,\n isWithin,\n mindMapTemplates,\n mindMapToBoard,\n mindMapToMarkdown,\n mindMapToText,\n moveTopic,\n normalizeMap,\n outdentTopic,\n outlineToTopics,\n parentOf,\n pathTo,\n removeTopic,\n setAllCollapsed,\n shiftTopic,\n updateTopic,\n} from \"./tree\";\n\nconst HISTORY = 100;\nconst EDITING_TARGET = 'input,textarea,select,[contenteditable=\"true\"],[contenteditable=\"plaintext-only\"],[role=\"dialog\"],[role=\"menu\"],[popover],button,a[href]';\nconst EXTRA_COLORS = [\"warning\", \"success\", \"info\", \"destructive\"];\nconst isMac = () => typeof navigator !== \"undefined\" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || \"\");\n\n// Measures words in the map's own font (a canvas's measureText), so a\n// topic's box fits its words exactly. Until the page is there (the\n// server), a guess. Measured again once mounted (the font of the element\n// the map sits in, not the body's) and when web fonts finish loading.\nfunction useMeasure(hostRef) {\n const [tick, setTick] = useState(0);\n const r = useRef({ ctx: null, family: null, cache: new Map() });\n useEffect(() => {\n let live = true;\n const again = () => {\n if (!live) return;\n r.current.cache.clear();\n r.current.family = null;\n setTick((t) => t + 1);\n };\n again();\n document.fonts?.ready?.then(again);\n document.fonts?.addEventListener?.(\"loadingdone\", again);\n return () => {\n live = false;\n document.fonts?.removeEventListener?.(\"loadingdone\", again);\n };\n }, []);\n return useCallback(\n (text, style) => {\n if (typeof document === \"undefined\") return estimateText(text, style);\n const c = r.current;\n if (!c.ctx) c.ctx = document.createElement(\"canvas\").getContext(\"2d\");\n if (!c.ctx) return estimateText(text, style);\n if (!c.family) {\n const cs = getComputedStyle(hostRef.current ?? document.body);\n c.family = cs.fontFamily || \"system-ui, sans-serif\";\n // A theme's tracking widens the words too (canvas text has it in\n // current browsers; elsewhere the +2px slack covers a little).\n if (\"letterSpacing\" in c.ctx) c.ctx.letterSpacing = cs.letterSpacing === \"normal\" ? \"0px\" : cs.letterSpacing;\n }\n const key = `${style.weight}|${style.font}|${text}`;\n let w = c.cache.get(key);\n if (w === undefined) {\n c.ctx.font = `${style.weight} ${style.font}px ${c.family}`;\n w = c.ctx.measureText(text).width;\n if (c.cache.size > 4000) c.cache.clear();\n c.cache.set(key, w);\n }\n return w;\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [tick],\n );\n}\n\nfunction useCoarsePointer() {\n const [coarse, setCoarse] = useState(false);\n useEffect(() => {\n const mq = window.matchMedia(\"(pointer: coarse)\");\n const on = () => setCoarse(mq.matches);\n on();\n mq.addEventListener(\"change\", on);\n return () => mq.removeEventListener(\"change\", on);\n }, []);\n return coarse;\n}\n\nasync function settle(fn, ...args) {\n try {\n const res = await fn(...args);\n return res && res.ok === false ? { state: \"error\", error: res.error || \"Couldn't save\" } : { state: \"saved\" };\n } catch (e) {\n return { state: \"error\", error: e?.message || \"Couldn't save\" };\n }\n}\n\nexport function MindMap({\n value,\n defaultValue,\n onChange,\n onSave,\n changeDelay = 400,\n readOnly = false,\n colors = BRANCH_COLORS,\n templates,\n placeholder = \"New topic\",\n height = 600,\n label = \"Mind map\",\n onSelectionChange,\n className,\n}) {\n const boxRef = useRef(null);\n const measure = useMeasure(boxRef);\n const coarse = useCoarsePointer();\n const canvas = useFlowCanvas();\n const cbs = useRef({});\n cbs.current = { onChange, onSave, onSelectionChange, changeDelay, readOnly };\n\n // ── the tree and its history ─────────────────────────────────────────\n const [tree, setTreeState] = useState(() => normalizeMap(value ?? defaultValue));\n const treeRef = useRef(tree);\n const setTree = (next) => {\n treeRef.current = next;\n setTreeState(next);\n };\n const [hist, setHistState] = useState({ past: [], future: [] });\n const histRef = useRef(hist);\n const setHist = (h) => {\n histRef.current = h;\n setHistState(h);\n };\n\n const [selectedId, setSelectedState] = useState(null);\n const selectedRef = useRef(null);\n const [editing, setEditingState] = useState(null); // { id, isNew, before, prevText }\n const editingRef = useRef(null);\n const setEditing = (e) => {\n editingRef.current = e;\n setEditingState(e);\n };\n const select = useCallback((id) => {\n selectedRef.current = id;\n setSelectedState(id);\n }, []);\n\n // ── saving ───────────────────────────────────────────────────────────\n const [save, setSave] = useState({ state: \"idle\" });\n const timer = useRef(0);\n const dirty = useRef(false);\n const seq = useRef(0);\n const lastEmitted = useRef(null);\n const runSave = async (t) => {\n const fn = cbs.current.onSave;\n if (!fn) return;\n const n = ++seq.current;\n setSave({ state: \"saving\" });\n const res = await settle(fn, t);\n if (n === seq.current) setSave(res);\n };\n const emitNow = () => {\n clearTimeout(timer.current);\n if (!dirty.current) return;\n dirty.current = false;\n const t = treeRef.current;\n lastEmitted.current = t;\n cbs.current.onChange?.(t);\n runSave(t);\n };\n const scheduleEmit = () => {\n if (cbs.current.readOnly) return;\n dirty.current = true;\n clearTimeout(timer.current);\n timer.current = setTimeout(emitNow, cbs.current.changeDelay ?? 400);\n };\n useEffect(\n () => () => {\n if (dirty.current) emitNow();\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [],\n );\n\n // A change the person made: the old tree goes on the undo stack.\n const commit = (next, { push = true } = {}) => {\n const cur = treeRef.current;\n if (next === cur) return false;\n if (push) setHist({ past: [...histRef.current.past, cur].slice(-HISTORY), future: [] });\n setTree(next);\n scheduleEmit();\n return true;\n };\n\n // Controlled: a `value` that is not our own echo replaces the map (and\n // can be undone).\n const lastProp = useRef(value);\n useEffect(() => {\n if (value === undefined || value === lastProp.current) return;\n lastProp.current = value;\n if (value === lastEmitted.current) return;\n const next = normalizeMap(value);\n setHist({ past: [...histRef.current.past, treeRef.current].slice(-HISTORY), future: [] });\n setTree(next);\n if (selectedRef.current && !findTopic(next, selectedRef.current)) select(null);\n if (editingRef.current) setEditing(null);\n }, [value, select]);\n\n // ── what the canvas draws ────────────────────────────────────────────\n const board = useMemo(() => mindMapToBoard(tree, { measure, colors }), [tree, measure, colors]);\n const sizes = useMemo(() => new Map(board.nodes.map((n) => [n.id, n])), [board]);\n\n // Fit the map in view once its words are measured. The canvas fits only\n // on its own first mount, before the topics are sized in the map's font\n // (and again when web fonts land), so without this the map opened off to\n // the top-left of an empty-looking canvas (2026-09-24).\n useEffect(() => {\n // Two frames after a short settle: the canvas syncs the new sizes from\n // `value` in an effect of its own, and fits against what it holds.\n let raf = 0;\n const t = setTimeout(() => {\n raf = requestAnimationFrame(() => (raf = requestAnimationFrame(() => canvas.current?.fitView({ duration: 0 }))));\n }, 300);\n return () => (clearTimeout(t), cancelAnimationFrame(raf));\n }, [measure]);\n\n // The canvas pans with a plain drag (its hand tool): a mind map has\n // nothing to lasso.\n const hand = useCallback(() => {\n const api = canvas.current;\n if (api && api.getTool?.() !== \"hand\") api.setTool(\"hand\");\n }, [canvas]);\n useEffect(() => {\n let raf = 0;\n let tries = 0;\n const wait = () => {\n if (canvas.current) return hand();\n if (tries++ < 120) raf = requestAnimationFrame(wait);\n };\n wait();\n return () => cancelAnimationFrame(raf);\n }, [canvas, hand]);\n\n // Keep a topic in view: centre it at the zoom there is, only when it is\n // (partly) off screen.\n const ensureVisible = useCallback(\n (id, tries = 3) => {\n const host = boxRef.current;\n const el = host?.querySelector(`[data-topic=\"${CSS.escape(id)}\"]`);\n if (!el) {\n if (tries > 0) setTimeout(() => ensureVisible(id, tries - 1), 60);\n return;\n }\n const r = el.getBoundingClientRect();\n const c = host.getBoundingClientRect();\n const pad = 32;\n if (r.left >= c.left + pad && r.right <= c.right - pad && r.top >= c.top + pad + 8 && r.bottom <= c.bottom - pad - 40) return;\n const w = sizes.get(id)?.width;\n const zoom = w ? r.width / w : 1;\n canvas.current?.fitView({ nodes: [{ id }], minZoom: zoom, maxZoom: zoom, padding: 0.5 });\n },\n [canvas, sizes],\n );\n\n // ── editing words ────────────────────────────────────────────────────\n const setText = useCallback((id, text) => {\n const next = updateTopic(treeRef.current, id, { text });\n setTree(next);\n scheduleEmit();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n const startEdit = useCallback(\n (id, { replace, isNew = false, before } = {}) => {\n if (readOnly) return;\n const cur = treeRef.current;\n const t = findTopic(cur, id);\n if (!t) return;\n select(id);\n const ed = { id, isNew, before: before ?? cur, prevText: t.text };\n if (replace !== undefined) setTree(updateTopic(cur, id, { text: replace }));\n setEditing(ed);\n },\n [readOnly, select],\n );\n\n /** End the words' edit. Empty: a topic just made goes away (as if it\n * never was), an old one gets its words back. */\n const finishEdit = useCallback((id) => {\n const ed = editingRef.current;\n if (!ed || (id && ed.id !== id)) return;\n setEditing(null);\n const cur = treeRef.current;\n const t = findTopic(cur, ed.id);\n if (!t) return;\n const text = String(t.text ?? \"\").replace(/\\s+$/, \"\").replace(/^\\s+/, \"\");\n if (!text) {\n if (ed.isNew && ed.id !== cur.id) {\n const prev = neighbourAfterRemove(cur, ed.id);\n setTree(removeTopic(cur, ed.id));\n const past = histRef.current.past;\n if (past[past.length - 1] === ed.before) setHist({ past: past.slice(0, -1), future: histRef.current.future });\n select(prev);\n } else {\n setTree(updateTopic(cur, ed.id, { text: ed.prevText }));\n }\n scheduleEmit();\n return;\n }\n if (text !== t.text) setTree(updateTopic(cur, ed.id, { text }));\n if (!ed.isNew && text !== ed.prevText) setHist({ past: [...histRef.current.past, ed.before].slice(-HISTORY), future: [] });\n scheduleEmit();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // ── changing the map ─────────────────────────────────────────────────\n const addChild = useCallback(\n (parentId) => {\n if (readOnly) return;\n finishEdit();\n const cur = treeRef.current;\n if (!findTopic(cur, parentId)) return;\n const t = createTopic(\"\");\n commit(insertTopic(cur, parentId, t));\n startEdit(t.id, { isNew: true, before: cur });\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, finishEdit, startEdit],\n );\n\n const addSibling = useCallback(\n (id, { before = false } = {}) => {\n if (readOnly) return;\n finishEdit();\n const cur = treeRef.current;\n if (id === cur.id) return addChild(id);\n const t = createTopic(\"\");\n commit(insertSibling(cur, id, t, { before }));\n startEdit(t.id, { isNew: true, before: cur });\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, finishEdit, startEdit, addChild],\n );\n\n const remove = useCallback(\n (id) => {\n if (readOnly) return;\n const cur = treeRef.current;\n const t = findTopic(cur, id);\n if (!t || id === cur.id) return;\n setEditing(null);\n const next = neighbourAfterRemove(cur, id);\n commit(removeTopic(cur, id));\n select(next);\n const n = countTopics(t);\n toast(`Deleted “${clip(t.text) || \"Untitled\"}”${n ? ` and ${n} under it` : \"\"}`, { action: { label: \"Undo\", onClick: () => undoRef.current?.() } });\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, select],\n );\n\n const toggleFold = useCallback(\n (id, want) => {\n const cur = treeRef.current;\n const t = findTopic(cur, id);\n if (!t || !t.children?.length || id === cur.id) return;\n const on = want ?? !t.collapsed;\n if (Boolean(t.collapsed) === on) return;\n if (readOnly) setTree(updateTopic(cur, id, { collapsed: on }));\n else commit(updateTopic(cur, id, { collapsed: on }));\n // A fold under the selection hides it: the selection moves up.\n const sel = selectedRef.current;\n if (on && sel && sel !== id && isWithin(cur, id, sel)) select(id);\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, select],\n );\n\n const setColor = (id, color) => {\n const cur = treeRef.current;\n if (!findTopic(cur, id) || id === cur.id) return;\n commit(updateTopic(cur, id, { color }));\n };\n\n const restructure = (fn, id, { keepEditing = false } = {}) => {\n const cur = treeRef.current;\n const next = fn(cur, id);\n if (next === cur) return;\n if (keepEditing && editingRef.current?.isNew) {\n // A topic still being made: its creation is the undo step.\n setTree(next);\n scheduleEmit();\n } else commit(next);\n select(id);\n };\n\n const undo = () => {\n finishEdit();\n const h = histRef.current;\n if (!h.past.length) return;\n const prev = h.past[h.past.length - 1];\n setHist({ past: h.past.slice(0, -1), future: [treeRef.current, ...h.future].slice(0, HISTORY) });\n setTree(prev);\n if (selectedRef.current && !findTopic(prev, selectedRef.current)) select(null);\n scheduleEmit();\n say(\"Undone\");\n };\n const redo = () => {\n finishEdit();\n const h = histRef.current;\n if (!h.future.length) return;\n const [next, ...future] = h.future;\n setHist({ past: [...h.past, treeRef.current].slice(-HISTORY), future });\n setTree(next);\n if (selectedRef.current && !findTopic(next, selectedRef.current)) select(null);\n scheduleEmit();\n say(\"Redone\");\n };\n const undoRef = useRef(undo);\n undoRef.current = undo;\n\n const loadMap = (map) => {\n finishEdit();\n commit(normalizeMap(map));\n select(null);\n setTimeout(() => canvas.current?.fitView(), 80);\n };\n\n // ── announcements ────────────────────────────────────────────────────\n const [announce, setAnnounce] = useState(\"\");\n const say = (msg) => setAnnounce((m) => (m === msg ? `${msg} ` : msg));\n\n // Selection: tell the page, keep it in view, and keep the keyboard's\n // focus on it (so a reader hears it).\n useEffect(() => {\n const t = selectedId ? findTopic(treeRef.current, selectedId) : null;\n cbs.current.onSelectionChange?.(t ?? null);\n if (!selectedId || editing) return;\n if (t) {\n const path = pathTo(treeRef.current, selectedId);\n const kids = t.children?.length ?? 0;\n say(`${t.text || \"Untitled\"}${path.length > 1 ? `, level ${path.length - 1}` : \", centre\"}${kids ? `, ${kids} subtopic${kids === 1 ? \"\" : \"s\"}${t.collapsed ? \", folded\" : \"\"}` : \"\"}`);\n }\n const raf = requestAnimationFrame(() => {\n const host = boxRef.current;\n if (!host) return;\n const el = host.querySelector(`.react-flow__node[data-id=\"${CSS.escape(selectedId)}\"]`);\n if (el && host.contains(document.activeElement) && document.activeElement !== el && !document.activeElement?.closest?.(\"[role=textbox]\")) el.focus({ preventScroll: true });\n ensureVisible(selectedId);\n });\n return () => cancelAnimationFrame(raf);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [selectedId, editing]);\n useEffect(() => {\n if (editing) ensureVisible(editing.id);\n }, [editing, ensureVisible]);\n\n // ── moving with the keys ─────────────────────────────────────────────\n const itemsById = useMemo(() => new Map(board.nodes.map((n) => [n.id, n])), [board]);\n const navigate = (key) => {\n const cur = treeRef.current;\n const id = selectedRef.current;\n const me = itemsById.get(id);\n if (!me) return select(cur.id);\n const side = me.data.side;\n const cy = me.position.y + me.height / 2;\n const nearest = (list) => list.sort((a, b) => Math.abs(a.position.y + a.height / 2 - cy) - Math.abs(b.position.y + b.height / 2 - cy))[0];\n const t = findTopic(cur, id);\n const kidsOn = (s) => (t.children ?? []).map((c) => itemsById.get(c.id)).filter((n) => n && (s === undefined || n.data.side === s));\n const outward = side === \"left\" ? \"ArrowLeft\" : \"ArrowRight\";\n if (side === \"root\") {\n if (key === \"ArrowRight\" || key === \"ArrowLeft\") {\n const hit = nearest(kidsOn(key === \"ArrowRight\" ? \"right\" : \"left\"));\n if (hit) select(hit.id);\n }\n return;\n }\n if (key === outward) {\n if (t.collapsed && t.children?.length) {\n toggleFold(id, false);\n select(t.children[0].id);\n return;\n }\n const hit = nearest(kidsOn());\n if (hit) select(hit.id);\n return;\n }\n if (key === \"ArrowLeft\" || key === \"ArrowRight\") return select(parentOf(cur, id)?.id ?? cur.id);\n // Up and down: the nearest topic above or below in the same column\n // (same side, same depth) — a sibling, or a cousin across a gap.\n const down = key === \"ArrowDown\";\n const column = board.nodes.filter((n) => n.id !== id && n.data.side === side && n.data.depth === me.data.depth);\n const cand = column\n .map((n) => ({ n, d: n.position.y + n.height / 2 - cy }))\n .filter(({ d }) => (down ? d > 1 : d < -1))\n .sort((a, b) => Math.abs(a.d) - Math.abs(b.d));\n if (cand[0]) select(cand[0].n.id);\n };\n\n // ── the keyboard ─────────────────────────────────────────────────────\n const [help, setHelp] = useState(false);\n const onKeyDownCapture = (e) => {\n if (e.nativeEvent.isComposing) return;\n const target = e.target;\n const k = e.key;\n const mod = e.metaKey || e.ctrlKey;\n const done = () => {\n e.preventDefault();\n e.stopPropagation();\n };\n const cur = treeRef.current;\n const ed = editingRef.current;\n\n // Typing a topic's words.\n if (ed && target?.closest?.(\"[role=textbox]\")) {\n const t = findTopic(cur, ed.id);\n const empty = !String(t?.text ?? \"\").trim();\n const isRoot = ed.id === cur.id;\n if (k === \"Enter\" && !e.shiftKey) {\n done();\n if (mod || isRoot) return finishEdit();\n if (empty && ed.isNew) {\n // An empty new line: step out a level, or stop.\n if (pathTo(cur, ed.id).length >= 3) return restructure(outdentTopic, ed.id, { keepEditing: true });\n return finishEdit();\n }\n finishEdit();\n return addSibling(ed.id);\n }\n if (k === \"Tab\") {\n done();\n if (isRoot) {\n finishEdit();\n return addChild(cur.id);\n }\n if (e.shiftKey) return restructure(outdentTopic, ed.id, { keepEditing: true });\n if (empty && ed.isNew) return restructure(indentTopic, ed.id, { keepEditing: true });\n finishEdit();\n return addChild(ed.id);\n }\n if (k === \"Escape\") {\n done();\n return finishEdit();\n }\n if (k === \"Backspace\" && empty && !isRoot && !(t?.children?.length)) {\n done();\n const prev = neighbourAfterRemove(cur, ed.id, { preferBefore: true });\n setEditing(null);\n if (ed.isNew) {\n setTree(removeTopic(cur, ed.id));\n const past = histRef.current.past;\n if (past[past.length - 1] === ed.before) setHist({ past: past.slice(0, -1), future: histRef.current.future });\n scheduleEmit();\n } else commit(removeTopic(cur, ed.id));\n if (prev && prev !== cur.id) startEdit(prev);\n else select(prev);\n return;\n }\n return;\n }\n if (target?.closest?.(EDITING_TARGET)) return;\n\n const sel = selectedRef.current && findTopic(cur, selectedRef.current) ? selectedRef.current : null;\n const low = k.length === 1 ? k.toLowerCase() : k;\n\n if (k === \"?\" && !mod) return done(), setHelp(true);\n if (mod && low === \"z\" && !readOnly) return done(), e.shiftKey ? redo() : undo();\n if (mod && low === \"y\" && !readOnly) return done(), redo();\n if (k === \"Escape\") {\n if (sel) return done(), select(null);\n setTimeout(hand, 0);\n return;\n }\n if (!sel) {\n if (k.startsWith(\"Arrow\") || k === \"Enter\" || k === \"Home\") return done(), select(cur.id);\n return;\n }\n if (k === \"Home\") return done(), select(cur.id);\n if (k.startsWith(\"Arrow\")) {\n if (mod && (k === \"ArrowUp\" || k === \"ArrowDown\")) return done(), toggleFold(sel, k === \"ArrowUp\");\n if (e.altKey && (k === \"ArrowUp\" || k === \"ArrowDown\")) return done(), !readOnly && restructure((r, id) => shiftTopic(r, id, k === \"ArrowUp\" ? -1 : 1), sel);\n if (mod || e.altKey) return;\n return done(), navigate(k);\n }\n if (readOnly) {\n if (k === \" \" || k === \"Enter\") return done(), toggleFold(sel);\n return;\n }\n if (k === \"Tab\") return done(), e.shiftKey ? restructure(outdentTopic, sel) : addChild(sel);\n if (k === \"Enter\") return done(), addSibling(sel, { before: e.shiftKey });\n if (k === \" \" || k === \"F2\") return done(), startEdit(sel);\n if (k === \"Delete\" || k === \"Backspace\") return done(), remove(sel);\n if (mod && low === \"d\") {\n done();\n const r = duplicateTopic(cur, sel);\n if (r.id) {\n commit(r.root);\n select(r.id);\n }\n return;\n }\n // ⇧1 and ⇧2 stay the canvas's (fit, zoom to selection).\n if (e.shiftKey && (e.code === \"Digit1\" || e.code === \"Digit2\")) return;\n if (!mod && !e.altKey && k.length === 1) return done(), startEdit(sel, { replace: k });\n };\n\n // Copy, cut, paste: a branch as an outline, and an outline (from notes,\n // a doc, an agent) as subtopics of the selection.\n useEffect(() => {\n const here = () => {\n const a = document.activeElement;\n return boxRef.current?.contains(a) && !a?.closest?.(EDITING_TARGET) && !a?.closest?.(\"[role=textbox]\");\n };\n const copy = (e) => {\n const id = selectedRef.current;\n const t = id && findTopic(treeRef.current, id);\n if (!here() || !t) return false;\n e.clipboardData?.setData(\"text/plain\", mindMapToText(t));\n e.preventDefault();\n e.stopPropagation();\n return true;\n };\n const onCopy = (e) => {\n if (copy(e)) say(\"Copied as an outline\");\n };\n const onCut = (e) => {\n if (readOnly) return;\n const id = selectedRef.current;\n if (id && id !== treeRef.current.id && copy(e)) remove(id);\n };\n const onPaste = (e) => {\n if (!here() || readOnly) return;\n const text = e.clipboardData?.getData(\"text/plain\") ?? \"\";\n const topics = outlineToTopics(text).slice(0, 200);\n e.preventDefault();\n e.stopPropagation();\n if (!topics.length) return;\n const cur = treeRef.current;\n const parent = selectedRef.current && findTopic(cur, selectedRef.current) ? selectedRef.current : cur.id;\n let next = cur;\n for (const t of topics) next = insertTopic(next, parent, t);\n commit(next);\n select(topics[0].id);\n say(`${topics.length} topic${topics.length === 1 ? \"\" : \"s\"} pasted`);\n };\n window.addEventListener(\"copy\", onCopy, true);\n window.addEventListener(\"cut\", onCut, true);\n window.addEventListener(\"paste\", onPaste, true);\n return () => {\n window.removeEventListener(\"copy\", onCopy, true);\n window.removeEventListener(\"cut\", onCut, true);\n window.removeEventListener(\"paste\", onPaste, true);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [readOnly]);\n\n // ── the pointer: select, drag a topic onto another ───────────────────\n const [drag, setDrag] = useState(null); // { id, text, x, y }\n const [dropId, setDropId] = useState(null);\n const onTopicPointerDown = useCallback(\n (e, id) => {\n if (editingRef.current && editingRef.current.id !== id) finishEdit();\n select(id);\n const cur = treeRef.current;\n if (readOnly || id === cur.id || e.pointerType !== \"mouse\") return;\n const start = { x: e.clientX, y: e.clientY };\n let active = false;\n let over = null;\n const t = findTopic(cur, id);\n const move = (ev) => {\n if (!active && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < 5) return;\n active = true;\n const host = boxRef.current?.getBoundingClientRect();\n if (!host) return;\n const hit = document\n .elementsFromPoint(ev.clientX, ev.clientY)\n .map((el) => el.closest?.(\"[data-topic]\"))\n .find((el) => el && el.dataset.topic !== id);\n const tid = hit?.dataset.topic ?? null;\n over = tid && !isWithin(treeRef.current, id, tid) && parentOf(treeRef.current, id)?.id !== tid ? tid : null;\n setDropId(over);\n setDrag({ id, text: t?.text || \"Untitled\", x: ev.clientX - host.left, y: ev.clientY - host.top });\n };\n const up = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n if (!active) return;\n setDrag(null);\n setDropId(null);\n if (!over) return;\n const cur2 = treeRef.current;\n const next = moveTopic(cur2, id, over);\n if (next !== cur2) {\n commit(next);\n select(id);\n const to = findTopic(next, over);\n say(`Moved under ${to?.text || \"Untitled\"}`);\n }\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, finishEdit, select],\n );\n\n // A click on the empty canvas (not a pan) clears the selection.\n const paneDown = useRef(null);\n const onPointerDownCapture = (e) => {\n paneDown.current = e.target?.classList?.contains(\"react-flow__pane\") ? { x: e.clientX, y: e.clientY } : null;\n };\n const onPointerUpCapture = (e) => {\n const p = paneDown.current;\n paneDown.current = null;\n if (!p || Math.hypot(e.clientX - p.x, e.clientY - p.y) > 4) return;\n finishEdit();\n select(null);\n };\n\n // ── the context the topics read ──────────────────────────────────────\n const ctx = useMemo(\n () => ({\n selectedId,\n editingId: editing?.id ?? null,\n dropId,\n dragId: drag?.id ?? null,\n readOnly,\n coarse,\n placeholder,\n onTopicPointerDown,\n startEdit,\n setText,\n finishEdit,\n toggleFold,\n addChild,\n }),\n [selectedId, editing, dropId, drag, readOnly, coarse, placeholder, onTopicPointerDown, startEdit, setText, finishEdit, toggleFold, addChild],\n );\n\n // ── the chrome ───────────────────────────────────────────────────────\n const [view, setView] = useState(\"map\");\n const selTopic = selectedId ? findTopic(tree, selectedId) : null;\n const selIsRoot = selTopic?.id === tree.id;\n const selColor = selectedId ? itemsById.get(selectedId)?.data.color : null;\n const empty = !(tree.children?.length ?? 0);\n const palette = useMemo(() => [...new Set([...colors, ...EXTRA_COLORS])], [colors]);\n const starters = templates ?? mindMapTemplates();\n const hint = editing\n ? [[\"Enter\", \"next topic\"], [\"Tab\", \"subtopic\"], [\"Esc\", \"done\"]]\n : selTopic\n ? readOnly\n ? [[\"Arrows\", \"move\"], [\"Space\", selTopic.collapsed ? \"unfold\" : \"fold\"]]\n : [[\"Tab\", \"subtopic\"], [\"Enter\", \"topic\"], [\"Space\", \"rename\"], [\"⌫\", \"delete\"]]\n : [[\"Click\", \"a topic\"], [\"Drag\", \"to pan\"], [\"?\", \"shortcuts\"]];\n const copyMarkdown = async () => {\n try {\n await navigator.clipboard.writeText(mindMapToMarkdown(treeRef.current));\n toast.success(\"Copied as Markdown\");\n } catch {\n toast.error(\"Couldn't copy\", { description: \"Your browser kept the clipboard closed.\" });\n }\n };\n const sized = /(^|\\s)(h-|min-h-|size-|flex-1|grow)/.test(className ?? \"\");\n\n return (\n <section\n aria-label={label}\n className={cx(\"flex flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground\", className)}\n style={sized ? undefined : { height }}\n >\n <header className=\"flex min-h-12 shrink-0 flex-wrap items-center gap-1 border-b border-border bg-card px-2 py-1.5 text-card-foreground\">\n {!readOnly ? (\n <>\n <Tooltip label={`Undo · ${isMac() ? \"⌘\" : \"Ctrl+\"}Z`}>\n <IconButton icon=\"undo\" label=\"Undo\" size=\"sm\" disabled={!hist.past.length} onClick={undo} />\n </Tooltip>\n <Tooltip label={`Redo · ${isMac() ? \"⇧⌘Z\" : \"Ctrl+Shift+Z\"}`}>\n <IconButton icon=\"redo\" label=\"Redo\" size=\"sm\" disabled={!hist.future.length} onClick={redo} />\n </Tooltip>\n <SaveState save={save} onRetry={() => runSave(treeRef.current)} />\n </>\n ) : (\n <p className=\"px-2 text-sm font-medium\">{clip(tree.text, 48) || \"Mind map\"}</p>\n )}\n <div className=\"flex-1\" />\n {view === \"map\" && !readOnly ? (\n <div role=\"toolbar\" aria-label=\"Topic\" className=\"flex items-center gap-1\">\n <Tooltip label={selTopic && !selIsRoot ? \"Add a topic below · Enter\" : \"Add a main topic\"}>\n <Button size=\"sm\" variant=\"quiet\" icon=\"plus\" onClick={() => (selTopic ? addSibling(selTopic.id) : addChild(tree.id))} aria-label=\"Add topic\">\n <span className=\"max-sm:hidden\">Topic</span>\n </Button>\n </Tooltip>\n <Tooltip label=\"Add a subtopic · Tab\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"git-branch\" onClick={() => addChild(selTopic?.id ?? tree.id)} aria-label=\"Add subtopic\">\n <span className=\"max-sm:hidden\">Subtopic</span>\n </Button>\n </Tooltip>\n <Separator orientation=\"vertical\" className=\"mx-0.5 h-5 max-sm:hidden\" />\n <Popover\n label=\"Branch colour\"\n width=\"auto\"\n className=\"p-1.5\"\n trigger={(p) => (\n <Button {...p} variant=\"quiet\" size=\"sm\" iconOnly aria-label=\"Branch colour\" title=\"Branch colour\" disabled={!selTopic || selIsRoot}>\n <ColorDot color={selColor ?? \"muted\"} />\n </Button>\n )}\n >\n <Palette colors={palette} value={selTopic?.color ?? selColor} swatch=\"dot\" onPick={(c) => selTopic && setColor(selTopic.id, c)} label=\"Branch colour\" />\n {selTopic?.color ? (\n <Button variant=\"quiet\" size=\"sm\" className=\"mt-1 w-full\" onClick={() => setColor(selTopic.id, undefined)}>\n Use the branch's colour\n </Button>\n ) : null}\n </Popover>\n <Tooltip label={selTopic?.collapsed ? \"Unfold · ⌘↓\" : \"Fold · ⌘↑\"}>\n <IconButton\n icon={selTopic?.collapsed ? \"eye\" : \"eye-off\"}\n label={selTopic?.collapsed ? \"Unfold branch\" : \"Fold branch\"}\n size=\"sm\"\n disabled={!selTopic || selIsRoot || !selTopic.children?.length}\n onClick={() => toggleFold(selTopic.id)}\n />\n </Tooltip>\n <Tooltip label=\"Delete · ⌫\">\n <IconButton icon=\"trash\" label=\"Delete topic\" size=\"sm\" disabled={!selTopic || selIsRoot} onClick={() => remove(selTopic.id)} />\n </Tooltip>\n </div>\n ) : null}\n <ToggleGroup\n size=\"sm\"\n className=\"max-sm:hidden\"\n aria-label=\"View\"\n value={view}\n onChange={setView}\n items={[\n { value: \"map\", label: \"Map\", icon: \"git-branch\", iconOnly: true },\n { value: \"outline\", label: \"Outline\", icon: \"list\", iconOnly: true },\n ]}\n />\n <Menu\n label=\"Map actions\"\n items={[\n { label: \"Unfold everything\", icon: \"eye\", onSelect: () => (readOnly ? setTree(setAllCollapsed(treeRef.current, false)) : commit(setAllCollapsed(treeRef.current, false))) },\n { label: \"Fold every branch\", icon: \"eye-off\", onSelect: () => (readOnly ? setTree(setAllCollapsed(treeRef.current, true)) : commit(setAllCollapsed(treeRef.current, true))) },\n { label: \"Fit to screen\", icon: \"maximize\", shortcut: \"⇧1\", onSelect: () => canvas.current?.fitView() },\n { type: \"checkbox\", label: \"Outline view\", checked: view === \"outline\", onCheckedChange: (on) => setView(on ? \"outline\" : \"map\") },\n { type: \"separator\" },\n { label: \"Copy as Markdown\", icon: \"copy\", onSelect: copyMarkdown },\n { label: \"Keyboard shortcuts\", icon: \"keyboard\", shortcut: \"?\", onSelect: () => setHelp(true) },\n ]}\n />\n </header>\n\n <div\n ref={boxRef}\n className=\"relative min-h-0 flex-1\"\n onKeyDownCapture={onKeyDownCapture}\n onPointerDownCapture={onPointerDownCapture}\n onPointerUpCapture={onPointerUpCapture}\n >\n <p className=\"sr-only\" aria-live=\"polite\">\n {announce}\n </p>\n <div className=\"absolute inset-0\" inert={view !== \"map\" || undefined} aria-hidden={view !== \"map\" || undefined}>\n <MindContext.Provider value={ctx}>\n <FlowCanvas\n value={board}\n readOnly\n nodeTypes={NODE_TYPES}\n canvasRef={canvas}\n label={label}\n height=\"100%\"\n bare\n emptyState={false}\n templates={[]}\n describeNode={describeTopic}\n className=\"[&_.react-flow__node]:transition-transform [&_.react-flow__node]:duration-200 [&_.react-flow__node]:ease-out motion-reduce:[&_.react-flow__node]:transition-none\"\n />\n </MindContext.Provider>\n </div>\n\n {view === \"map\" && drag ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute z-20 grid -translate-x-1/2 -translate-y-[calc(100%+10px)] justify-items-center gap-1\" style={{ left: drag.x, top: drag.y }}>\n <span className=\"max-w-60 truncate rounded-lg border border-border bg-popover px-3 py-1.5 text-sm font-medium text-popover-foreground shadow-lg\">{clip(drag.text, 40)}</span>\n <span className=\"rounded-md bg-foreground px-2 py-0.5 text-xs text-background\">{dropId ? `Move under “${clip(findTopic(tree, dropId)?.text, 24) || \"Untitled\"}”` : \"Drop on a topic\"}</span>\n </div>\n ) : null}\n\n {view === \"map\" && empty && !readOnly && !editing ? (\n <Starter templates={starters} onPick={(t) => loadMap(typeof t.map === \"function\" ? t.map() : t.map)} onStart={() => addChild(tree.id)} />\n ) : null}\n\n {view === \"map\" ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute bottom-3 left-3 z-10 hidden max-w-[calc(100%-12rem)] flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-border bg-card/90 px-2.5 py-1.5 text-xs text-muted-foreground shadow-xs backdrop-blur-sm md:flex\">\n {hint.map(([key, what]) => (\n <span key={key} className=\"flex items-center gap-1.5 whitespace-nowrap\">\n <Kbd>{key}</Kbd>\n {what}\n </span>\n ))}\n </div>\n ) : null}\n\n {view === \"outline\" ? (\n <OutlineView\n tree={tree}\n items={itemsById}\n onPick={(id) => {\n setView(\"map\");\n // Open the folds on the way down, so the topic can be seen.\n const path = pathTo(treeRef.current, id);\n let next = treeRef.current;\n for (const p of path.slice(0, -1)) if (p.collapsed) next = updateTopic(next, p.id, { collapsed: false });\n if (next !== treeRef.current) (readOnly ? setTree(next) : commit(next));\n select(id);\n }}\n onCopy={copyMarkdown}\n />\n ) : null}\n </div>\n\n <Shortcuts open={help} onClose={() => setHelp(false)} readOnly={readOnly} />\n </section>\n );\n}\n\n// ── pieces ─────────────────────────────────────────────────────────────\n\nconst clip = (s, n = 60) => {\n const t = String(s ?? \"\").replace(/\\s+/g, \" \").trim();\n return t.length > n ? `${t.slice(0, n - 1)}…` : t;\n};\n\nfunction describeTopic(n) {\n if (n.type !== \"topic\") return undefined;\n const d = n.data ?? {};\n return `${d.depth === 0 ? \"Centre\" : `Level ${d.depth}`}: \"${clip(d.text) || \"Untitled\"}\"${d.collapsed ? ` (folded, ${d.hidden} hidden)` : \"\"}`;\n}\n\n/** Where the selection goes when a topic leaves: the next sibling, else\n * the one before, else the parent. */\nfunction neighbourAfterRemove(root, id, { preferBefore = false } = {}) {\n const parent = parentOf(root, id);\n if (!parent) return root.id;\n const kids = parent.children;\n const i = kids.findIndex((c) => c.id === id);\n const order = preferBefore ? [kids[i - 1], kids[i + 1]] : [kids[i + 1], kids[i - 1]];\n return (order.find(Boolean) ?? parent).id;\n}\n\nfunction SaveState({ save, onRetry }) {\n if (save.state === \"error\") {\n return (\n <div role=\"alert\" className=\"flex min-w-0 items-center gap-1 pl-1 text-xs\">\n <Icon name=\"warning\" size={14} className=\"shrink-0 text-destructive\" />\n <span className=\"truncate max-md:sr-only\">{save.error || \"Couldn't save\"}</span>\n <Button variant=\"quiet\" size=\"sm\" onClick={onRetry}>\n Retry\n </Button>\n </div>\n );\n }\n if (save.state === \"idle\") return null;\n const saving = save.state === \"saving\";\n return (\n <p role=\"status\" className=\"flex shrink-0 items-center gap-1 px-1 text-xs text-muted-foreground\">\n <Icon name={saving ? \"loader\" : \"check\"} size={14} className={cx(saving && \"motion-safe:animate-spin\")} />\n <span className=\"max-sm:sr-only\">{saving ? \"Saving…\" : \"Saved\"}</span>\n </p>\n );\n}\n\n/** The first run: how the map works, and a few starting points. */\nfunction Starter({ templates, onPick, onStart }) {\n return (\n <div className=\"pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center md:bottom-14\">\n <div className=\"pointer-events-auto w-full max-w-md rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-lg\">\n <p className=\"text-sm font-semibold\">Start with the big idea</p>\n <p className=\"mt-1 text-sm text-muted-foreground\">\n Double-click the centre to name it. <Kbd>Tab</Kbd> adds a branch, <Kbd>Enter</Kbd> the next one — no mouse needed.\n </p>\n <div className=\"mt-3 flex flex-wrap gap-2\">\n <Button size=\"sm\" variant=\"primary\" icon=\"plus\" onClick={onStart}>\n Add a branch\n </Button>\n {templates.map((t) => (\n <Tooltip key={t.id} label={t.description ?? t.label}>\n <Button size=\"sm\" icon={t.icon} onClick={() => onPick(t)}>\n {t.label}\n </Button>\n </Tooltip>\n ))}\n </div>\n </div>\n </div>\n );\n}\n\n/** The map as a nested list: the reading of it for a screen reader, and\n * a quick way to jump to a topic. */\nfunction OutlineView({ tree, items, onPick, onCopy }) {\n const rows = [];\n const walk = (t, depth, color) => {\n const c = items.get(t.id)?.data.color ?? color;\n rows.push({ t, depth, color: c });\n for (const k of t.children ?? []) walk(k, depth + 1, c);\n };\n walk(tree, 0, null);\n return (\n <div className=\"absolute inset-0 z-10 overflow-y-auto bg-background\">\n <div className=\"mx-auto grid max-w-2xl gap-4 px-4 py-6 sm:px-6\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <div className=\"min-w-0\">\n <h3 className=\"truncate text-base font-semibold\">{tree.text || \"Untitled\"}</h3>\n <p className=\"text-sm text-muted-foreground\">\n {countTopics(tree)} topic{countTopics(tree) === 1 ? \"\" : \"s\"}. Choose one to find it on the map.\n </p>\n </div>\n <Button size=\"sm\" icon=\"copy\" onClick={onCopy}>\n Copy as Markdown\n </Button>\n </div>\n {rows.length > 1 ? (\n <ul className=\"grid gap-0.5\" aria-label=\"Outline\">\n {rows.slice(1).map(({ t, depth, color }) => (\n <li key={t.id} style={{ paddingLeft: (depth - 1) * 20 }}>\n <Button variant=\"quiet\" size=\"sm\" align=\"start\" className=\"w-full min-w-0\" onClick={() => onPick(t.id)}>\n <ColorDot color={color ?? \"muted\"} className={depth > 1 ? \"size-2\" : \"size-2.5\"} />\n <span className={cx(\"truncate\", depth === 1 ? \"font-medium\" : \"font-normal\")}>{t.text || \"Untitled\"}</span>\n {t.collapsed && t.children?.length ? <span className=\"ml-auto shrink-0 text-xs text-muted-foreground tabular-nums\">{countTopics(t)} folded</span> : null}\n </Button>\n </li>\n ))}\n </ul>\n ) : (\n <div className=\"grid justify-items-center gap-2 rounded-lg border border-dashed border-border px-4 py-10 text-center\">\n <p className=\"text-sm text-muted-foreground\">Only the centre so far. Branches you add show up here as a list.</p>\n </div>\n )}\n </div>\n </div>\n );\n}\n\nconst KEYS = [\n [\"Tab\", \"Add a subtopic\"],\n [\"Enter\", \"Add the next topic (⇧ above)\"],\n [\"Space\", \"Rename (or just start typing)\"],\n [\"⌫\", \"Delete the topic and its branch\"],\n [\"← → ↑ ↓\", \"Move around the map\"],\n [\"⌥↑ ⌥↓\", \"Move the topic up or down\"],\n [\"⇧Tab\", \"Move the topic up a level\"],\n [\"⌘↑ ⌘↓\", \"Fold, unfold\"],\n [\"⌘D\", \"Duplicate the branch\"],\n [\"⌘C ⌘V\", \"Copy a branch, paste an outline\"],\n [\"⌘Z\", \"Undo (⇧⌘Z redo)\"],\n [\"⇧1\", \"Fit the map to the screen\"],\n];\nconst TYPING = [\n [\"Enter\", \"Next topic (an empty one steps out a level)\"],\n [\"Tab\", \"Subtopic (an empty one moves in a level)\"],\n [\"⇧Enter\", \"A new line in the words\"],\n [\"Esc\", \"Done\"],\n];\n\nfunction Shortcuts({ open, onClose, readOnly }) {\n const mac = isMac();\n const key = (k) => (mac ? k : k.replace(/⌘/g, \"Ctrl+\").replace(/⌥/g, \"Alt+\").replace(/⇧/g, \"Shift+\"));\n const rows = readOnly ? [[\"← → ↑ ↓\", \"Move around the map\"], [\"Space\", \"Fold, unfold\"], [\"⇧1\", \"Fit the map to the screen\"]] : KEYS;\n const list = (items) => (\n <dl className=\"grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2 text-sm\">\n {items.map(([k, what]) => (\n <div key={k} className=\"contents\">\n <dt className=\"justify-self-start\">\n <Kbd>{key(k)}</Kbd>\n </dt>\n <dd className=\"text-muted-foreground\">{what}</dd>\n </div>\n ))}\n </dl>\n );\n return (\n <Sheet open={open} onClose={onClose} title=\"Keyboard shortcuts\" description=\"A mind map you can build without the mouse.\">\n <div className=\"grid gap-6\">\n <section className=\"grid gap-3\">\n <h3 className=\"text-sm font-medium\">With a topic selected</h3>\n {list(rows)}\n </section>\n {!readOnly ? (\n <section className=\"grid gap-3\">\n <h3 className=\"text-sm font-medium\">While typing a topic</h3>\n {list(TYPING)}\n </section>\n ) : null}\n </div>\n </Sheet>\n );\n}\n"
|
|
23
|
+
"content": "\"use client\";\n\n// MindMap — a mind map you can drive from the keyboard alone, on\n// FlowCanvas. The centre topic, main branches balanced on both sides, each\n// branch a tidy tree in its own chart hue. Tab adds a subtopic, Enter the\n// next topic, typing renames, arrows walk the map, ⌘↑/⌘↓ fold; drag a\n// topic onto another to move its branch there.\n//\n// The map is a tree of topics (tree.js), and this component owns it: every\n// change makes a new tree, pushes the old one on the undo stack, lays the\n// tree out again and hands the canvas the result as its `value`. The\n// canvas runs read-only (its own tools, stickies and undo never see this\n// map); pointer and keys come through this component instead, and the\n// canvas pans, zooms, fits and draws.\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, Icon, IconButton, Kbd, Menu, Popover, Separator, Sheet, ToggleGroup, Tooltip, cx, toast } from \"../../ui\";\nimport { ColorDot, FlowCanvas, Palette, useFlowCanvas } from \"../flow-canvas\";\nimport { MindContext, NODE_TYPES } from \"./topic\";\nimport {\n BRANCH_COLORS,\n countTopics,\n createTopic,\n duplicateTopic,\n estimateText,\n findTopic,\n indentTopic,\n insertSibling,\n insertTopic,\n isWithin,\n mindMapTemplates,\n mindMapToBoard,\n mindMapToMarkdown,\n mindMapToText,\n moveTopic,\n normalizeMap,\n openingView,\n outdentTopic,\n outlineToTopics,\n parentOf,\n pathTo,\n removeTopic,\n setAllCollapsed,\n shiftTopic,\n updateTopic,\n} from \"./tree\";\n\nconst HISTORY = 100;\nconst EDITING_TARGET = 'input,textarea,select,[contenteditable=\"true\"],[contenteditable=\"plaintext-only\"],[role=\"dialog\"],[role=\"menu\"],[popover],button,a[href]';\nconst EXTRA_COLORS = [\"warning\", \"success\", \"info\", \"destructive\"];\nconst isMac = () => typeof navigator !== \"undefined\" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || \"\");\n\n// Measures words in the map's own font (a canvas's measureText), so a\n// topic's box fits its words exactly. Until the map is mounted, a guess —\n// on the server AND in the browser's first render, which must lay the\n// map out exactly as the server did: measured there, the topics landed\n// elsewhere, the canvas's placeholder listed them in another order, and\n// React threw a hydration error on every load of a saved map\n// (makersclaw-store BLOCKS.md, mind-maps 0.1.0). Measured once mounted\n// (the font of the element the map sits in, not the body's) and again\n// when web fonts finish loading.\nfunction useMeasure(hostRef) {\n const [tick, setTick] = useState(0);\n const r = useRef({ ctx: null, family: null, cache: new Map(), mounted: false });\n useEffect(() => {\n let live = true;\n r.current.mounted = true;\n const again = () => {\n if (!live) return;\n r.current.cache.clear();\n r.current.family = null;\n setTick((t) => t + 1);\n };\n again();\n document.fonts?.ready?.then(again);\n document.fonts?.addEventListener?.(\"loadingdone\", again);\n return () => {\n live = false;\n document.fonts?.removeEventListener?.(\"loadingdone\", again);\n };\n }, []);\n return useCallback(\n (text, style) => {\n const c = r.current;\n if (typeof document === \"undefined\" || !c.mounted) return estimateText(text, style);\n if (!c.ctx) c.ctx = document.createElement(\"canvas\").getContext(\"2d\");\n if (!c.ctx) return estimateText(text, style);\n if (!c.family) {\n const cs = getComputedStyle(hostRef.current ?? document.body);\n c.family = cs.fontFamily || \"system-ui, sans-serif\";\n // A theme's tracking widens the words too (canvas text has it in\n // current browsers; elsewhere the +2px slack covers a little).\n if (\"letterSpacing\" in c.ctx) c.ctx.letterSpacing = cs.letterSpacing === \"normal\" ? \"0px\" : cs.letterSpacing;\n }\n const key = `${style.weight}|${style.font}|${text}`;\n let w = c.cache.get(key);\n if (w === undefined) {\n c.ctx.font = `${style.weight} ${style.font}px ${c.family}`;\n w = c.ctx.measureText(text).width;\n if (c.cache.size > 4000) c.cache.clear();\n c.cache.set(key, w);\n }\n return w;\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [tick],\n );\n}\n\nfunction useCoarsePointer() {\n const [coarse, setCoarse] = useState(false);\n useEffect(() => {\n const mq = window.matchMedia(\"(pointer: coarse)\");\n const on = () => setCoarse(mq.matches);\n on();\n mq.addEventListener(\"change\", on);\n return () => mq.removeEventListener(\"change\", on);\n }, []);\n return coarse;\n}\n\n/** The same map, as far as the view goes: the same centre topic and the\n * same main branches (in any order). */\nexport function sameBranches(a, b) {\n if (!a || !b || a.id !== b.id) return false;\n const ids = new Set((a.children ?? []).map((c) => c.id));\n const next = b.children ?? [];\n return next.length === ids.size && next.every((c) => ids.has(c.id));\n}\n\nasync function settle(fn, ...args) {\n try {\n const res = await fn(...args);\n return res && res.ok === false ? { state: \"error\", error: res.error || \"Couldn't save\" } : { state: \"saved\" };\n } catch (e) {\n return { state: \"error\", error: e?.message || \"Couldn't save\" };\n }\n}\n\nexport function MindMap({\n value,\n defaultValue,\n onChange,\n onSave,\n changeDelay = 400,\n readOnly = false,\n colors = BRANCH_COLORS,\n templates,\n placeholder = \"New topic\",\n height = 600,\n label = \"Mind map\",\n onSelectionChange,\n className,\n}) {\n const boxRef = useRef(null);\n const measure = useMeasure(boxRef);\n const coarse = useCoarsePointer();\n const canvas = useFlowCanvas();\n const cbs = useRef({});\n cbs.current = { onChange, onSave, onSelectionChange, changeDelay, readOnly };\n\n // ── the tree and its history ─────────────────────────────────────────\n const [tree, setTreeState] = useState(() => normalizeMap(value ?? defaultValue));\n const treeRef = useRef(tree);\n const setTree = (next) => {\n treeRef.current = next;\n setTreeState(next);\n };\n const [hist, setHistState] = useState({ past: [], future: [] });\n const histRef = useRef(hist);\n const setHist = (h) => {\n histRef.current = h;\n setHistState(h);\n };\n\n const [selectedId, setSelectedState] = useState(null);\n const selectedRef = useRef(null);\n const [editing, setEditingState] = useState(null); // { id, isNew, before, prevText }\n const editingRef = useRef(null);\n const setEditing = (e) => {\n editingRef.current = e;\n setEditingState(e);\n };\n const select = useCallback((id) => {\n selectedRef.current = id;\n setSelectedState(id);\n }, []);\n\n // ── saving ───────────────────────────────────────────────────────────\n const [save, setSave] = useState({ state: \"idle\" });\n const timer = useRef(0);\n const dirty = useRef(false);\n const seq = useRef(0);\n const lastEmitted = useRef(null);\n const runSave = async (t) => {\n const fn = cbs.current.onSave;\n if (!fn) return;\n const n = ++seq.current;\n setSave({ state: \"saving\" });\n const res = await settle(fn, t);\n if (n === seq.current) setSave(res);\n };\n const emitNow = () => {\n clearTimeout(timer.current);\n if (!dirty.current) return;\n dirty.current = false;\n const t = treeRef.current;\n lastEmitted.current = t;\n cbs.current.onChange?.(t);\n runSave(t);\n };\n const scheduleEmit = () => {\n if (cbs.current.readOnly) return;\n dirty.current = true;\n clearTimeout(timer.current);\n timer.current = setTimeout(emitNow, cbs.current.changeDelay ?? 400);\n };\n useEffect(\n () => () => {\n if (dirty.current) emitNow();\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [],\n );\n\n // A change the person made: the old tree goes on the undo stack.\n const commit = (next, { push = true } = {}) => {\n const cur = treeRef.current;\n if (next === cur) return false;\n if (push) setHist({ past: [...histRef.current.past, cur].slice(-HISTORY), future: [] });\n setTree(next);\n scheduleEmit();\n return true;\n };\n\n // Controlled: a `value` that is not our own echo replaces the map (and\n // can be undone). One that is another map — a record loaded, the\n // agent's first map over the starter — is fitted to the view like a\n // first one: until 0.5.0 it stayed at the old map's zoom and place, and\n // mind-maps remounted the block to get a fit. The same map back from a\n // save (its branches unchanged) keeps the view where it is.\n const lastProp = useRef(value);\n const [refit, setRefit] = useState(0);\n useEffect(() => {\n if (value === undefined || value === lastProp.current) return;\n lastProp.current = value;\n if (value === lastEmitted.current) return;\n const next = normalizeMap(value);\n const before = treeRef.current;\n setHist({ past: [...histRef.current.past, before].slice(-HISTORY), future: [] });\n setTree(next);\n if (selectedRef.current && !findTopic(next, selectedRef.current)) select(null);\n if (editingRef.current) setEditing(null);\n if (!sameBranches(before, next)) setRefit((n) => n + 1);\n }, [value, select]);\n\n // ── what the canvas draws ────────────────────────────────────────────\n const board = useMemo(() => mindMapToBoard(tree, { measure, colors }), [tree, measure, colors]);\n const sizes = useMemo(() => new Map(board.nodes.map((n) => [n.id, n])), [board]);\n const boardRef = useRef(board);\n boardRef.current = board;\n\n // Open a map fitted to the view, but never smaller than its words can be\n // read: a map too big for that opens at READABLE_ZOOM on its centre\n // topic (openingView). ⇧1 and \"Fit to screen\" still fit the whole map.\n const fitOpen = useCallback(\n (opts) => {\n const api = canvas.current;\n if (!api) return;\n const pane = boxRef.current?.querySelector(\".react-flow\") ?? boxRef.current;\n api.fitView({ ...opts, ...openingView(boardRef.current.nodes, { width: pane?.clientWidth, height: pane?.clientHeight, rootId: treeRef.current.id }) });\n },\n [canvas],\n );\n\n // Fit the map in view once its words are measured, and again when a\n // `value` puts another map in its place (above). The canvas fits only\n // on its own first mount, before the topics are sized in the map's font\n // (and again when web fonts land), so without this the map opened off to\n // the top-left of an empty-looking canvas (2026-09-24).\n useEffect(() => {\n // Two frames after a short settle: the canvas syncs the new sizes from\n // `value` in an effect of its own, and fits against what it holds.\n let raf = 0;\n const t = setTimeout(() => {\n raf = requestAnimationFrame(() => (raf = requestAnimationFrame(() => fitOpen({ duration: 0 }))));\n }, 300);\n return () => (clearTimeout(t), cancelAnimationFrame(raf));\n }, [measure, refit, fitOpen]);\n\n // The canvas pans with a plain drag (its hand tool): a mind map has\n // nothing to lasso.\n const hand = useCallback(() => {\n const api = canvas.current;\n if (api && api.getTool?.() !== \"hand\") api.setTool(\"hand\");\n }, [canvas]);\n useEffect(() => {\n let raf = 0;\n let tries = 0;\n const wait = () => {\n if (canvas.current) return hand();\n if (tries++ < 120) raf = requestAnimationFrame(wait);\n };\n wait();\n return () => cancelAnimationFrame(raf);\n }, [canvas, hand]);\n\n // Keep a topic in view: centre it at the zoom there is, only when it is\n // (partly) off screen.\n const ensureVisible = useCallback(\n (id, tries = 3) => {\n const host = boxRef.current;\n const el = host?.querySelector(`[data-topic=\"${CSS.escape(id)}\"]`);\n if (!el) {\n if (tries > 0) setTimeout(() => ensureVisible(id, tries - 1), 60);\n return;\n }\n const r = el.getBoundingClientRect();\n const c = host.getBoundingClientRect();\n const pad = 32;\n if (r.left >= c.left + pad && r.right <= c.right - pad && r.top >= c.top + pad + 8 && r.bottom <= c.bottom - pad - 40) return;\n const w = sizes.get(id)?.width;\n const zoom = w ? r.width / w : 1;\n canvas.current?.fitView({ nodes: [{ id }], minZoom: zoom, maxZoom: zoom, padding: 0.5 });\n },\n [canvas, sizes],\n );\n\n // ── editing words ────────────────────────────────────────────────────\n const setText = useCallback((id, text) => {\n const next = updateTopic(treeRef.current, id, { text });\n setTree(next);\n scheduleEmit();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n const startEdit = useCallback(\n (id, { replace, isNew = false, before } = {}) => {\n if (readOnly) return;\n const cur = treeRef.current;\n const t = findTopic(cur, id);\n if (!t) return;\n select(id);\n const ed = { id, isNew, before: before ?? cur, prevText: t.text };\n if (replace !== undefined) setTree(updateTopic(cur, id, { text: replace }));\n setEditing(ed);\n },\n [readOnly, select],\n );\n\n /** End the words' edit. Empty: a topic just made goes away (as if it\n * never was), an old one gets its words back. */\n const finishEdit = useCallback((id) => {\n const ed = editingRef.current;\n if (!ed || (id && ed.id !== id)) return;\n setEditing(null);\n const cur = treeRef.current;\n const t = findTopic(cur, ed.id);\n if (!t) return;\n const text = String(t.text ?? \"\").replace(/\\s+$/, \"\").replace(/^\\s+/, \"\");\n if (!text) {\n if (ed.isNew && ed.id !== cur.id) {\n const prev = neighbourAfterRemove(cur, ed.id);\n setTree(removeTopic(cur, ed.id));\n const past = histRef.current.past;\n if (past[past.length - 1] === ed.before) setHist({ past: past.slice(0, -1), future: histRef.current.future });\n select(prev);\n } else {\n setTree(updateTopic(cur, ed.id, { text: ed.prevText }));\n }\n scheduleEmit();\n return;\n }\n if (text !== t.text) setTree(updateTopic(cur, ed.id, { text }));\n if (!ed.isNew && text !== ed.prevText) setHist({ past: [...histRef.current.past, ed.before].slice(-HISTORY), future: [] });\n scheduleEmit();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // ── changing the map ─────────────────────────────────────────────────\n const addChild = useCallback(\n (parentId) => {\n if (readOnly) return;\n finishEdit();\n const cur = treeRef.current;\n if (!findTopic(cur, parentId)) return;\n const t = createTopic(\"\");\n commit(insertTopic(cur, parentId, t));\n startEdit(t.id, { isNew: true, before: cur });\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, finishEdit, startEdit],\n );\n\n const addSibling = useCallback(\n (id, { before = false } = {}) => {\n if (readOnly) return;\n finishEdit();\n const cur = treeRef.current;\n if (id === cur.id) return addChild(id);\n const t = createTopic(\"\");\n commit(insertSibling(cur, id, t, { before }));\n startEdit(t.id, { isNew: true, before: cur });\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, finishEdit, startEdit, addChild],\n );\n\n const remove = useCallback(\n (id) => {\n if (readOnly) return;\n const cur = treeRef.current;\n const t = findTopic(cur, id);\n if (!t || id === cur.id) return;\n setEditing(null);\n const next = neighbourAfterRemove(cur, id);\n commit(removeTopic(cur, id));\n select(next);\n const n = countTopics(t);\n toast(`Deleted “${clip(t.text) || \"Untitled\"}”${n ? ` and ${n} under it` : \"\"}`, { action: { label: \"Undo\", onClick: () => undoRef.current?.() } });\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, select],\n );\n\n const toggleFold = useCallback(\n (id, want) => {\n const cur = treeRef.current;\n const t = findTopic(cur, id);\n if (!t || !t.children?.length || id === cur.id) return;\n const on = want ?? !t.collapsed;\n if (Boolean(t.collapsed) === on) return;\n if (readOnly) setTree(updateTopic(cur, id, { collapsed: on }));\n else commit(updateTopic(cur, id, { collapsed: on }));\n // A fold under the selection hides it: the selection moves up.\n const sel = selectedRef.current;\n if (on && sel && sel !== id && isWithin(cur, id, sel)) select(id);\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, select],\n );\n\n const setColor = (id, color) => {\n const cur = treeRef.current;\n if (!findTopic(cur, id) || id === cur.id) return;\n commit(updateTopic(cur, id, { color }));\n };\n\n const restructure = (fn, id, { keepEditing = false } = {}) => {\n const cur = treeRef.current;\n const next = fn(cur, id);\n if (next === cur) return;\n if (keepEditing && editingRef.current?.isNew) {\n // A topic still being made: its creation is the undo step.\n setTree(next);\n scheduleEmit();\n } else commit(next);\n select(id);\n };\n\n const undo = () => {\n finishEdit();\n const h = histRef.current;\n if (!h.past.length) return;\n const prev = h.past[h.past.length - 1];\n setHist({ past: h.past.slice(0, -1), future: [treeRef.current, ...h.future].slice(0, HISTORY) });\n setTree(prev);\n if (selectedRef.current && !findTopic(prev, selectedRef.current)) select(null);\n scheduleEmit();\n say(\"Undone\");\n };\n const redo = () => {\n finishEdit();\n const h = histRef.current;\n if (!h.future.length) return;\n const [next, ...future] = h.future;\n setHist({ past: [...h.past, treeRef.current].slice(-HISTORY), future });\n setTree(next);\n if (selectedRef.current && !findTopic(next, selectedRef.current)) select(null);\n scheduleEmit();\n say(\"Redone\");\n };\n const undoRef = useRef(undo);\n undoRef.current = undo;\n\n const loadMap = (map) => {\n finishEdit();\n commit(normalizeMap(map));\n select(null);\n setTimeout(() => fitOpen(), 80);\n };\n\n // ── announcements ────────────────────────────────────────────────────\n const [announce, setAnnounce] = useState(\"\");\n const say = (msg) => setAnnounce((m) => (m === msg ? `${msg} ` : msg));\n\n // Selection: tell the page, keep it in view, and keep the keyboard's\n // focus on it (so a reader hears it).\n useEffect(() => {\n const t = selectedId ? findTopic(treeRef.current, selectedId) : null;\n cbs.current.onSelectionChange?.(t ?? null);\n if (!selectedId || editing) return;\n if (t) {\n const path = pathTo(treeRef.current, selectedId);\n const kids = t.children?.length ?? 0;\n say(`${t.text || \"Untitled\"}${path.length > 1 ? `, level ${path.length - 1}` : \", centre\"}${kids ? `, ${kids} subtopic${kids === 1 ? \"\" : \"s\"}${t.collapsed ? \", folded\" : \"\"}` : \"\"}`);\n }\n const raf = requestAnimationFrame(() => {\n const host = boxRef.current;\n if (!host) return;\n const el = host.querySelector(`.react-flow__node[data-id=\"${CSS.escape(selectedId)}\"]`);\n if (el && host.contains(document.activeElement) && document.activeElement !== el && !document.activeElement?.closest?.(\"[role=textbox]\")) el.focus({ preventScroll: true });\n ensureVisible(selectedId);\n });\n return () => cancelAnimationFrame(raf);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [selectedId, editing]);\n useEffect(() => {\n if (editing) ensureVisible(editing.id);\n }, [editing, ensureVisible]);\n\n // ── moving with the keys ─────────────────────────────────────────────\n const itemsById = useMemo(() => new Map(board.nodes.map((n) => [n.id, n])), [board]);\n const navigate = (key) => {\n const cur = treeRef.current;\n const id = selectedRef.current;\n const me = itemsById.get(id);\n if (!me) return select(cur.id);\n const side = me.data.side;\n const cy = me.position.y + me.height / 2;\n const nearest = (list) => list.sort((a, b) => Math.abs(a.position.y + a.height / 2 - cy) - Math.abs(b.position.y + b.height / 2 - cy))[0];\n const t = findTopic(cur, id);\n const kidsOn = (s) => (t.children ?? []).map((c) => itemsById.get(c.id)).filter((n) => n && (s === undefined || n.data.side === s));\n const outward = side === \"left\" ? \"ArrowLeft\" : \"ArrowRight\";\n if (side === \"root\") {\n if (key === \"ArrowRight\" || key === \"ArrowLeft\") {\n const hit = nearest(kidsOn(key === \"ArrowRight\" ? \"right\" : \"left\"));\n if (hit) select(hit.id);\n }\n return;\n }\n if (key === outward) {\n if (t.collapsed && t.children?.length) {\n toggleFold(id, false);\n select(t.children[0].id);\n return;\n }\n const hit = nearest(kidsOn());\n if (hit) select(hit.id);\n return;\n }\n if (key === \"ArrowLeft\" || key === \"ArrowRight\") return select(parentOf(cur, id)?.id ?? cur.id);\n // Up and down: the nearest topic above or below in the same column\n // (same side, same depth) — a sibling, or a cousin across a gap.\n const down = key === \"ArrowDown\";\n const column = board.nodes.filter((n) => n.id !== id && n.data.side === side && n.data.depth === me.data.depth);\n const cand = column\n .map((n) => ({ n, d: n.position.y + n.height / 2 - cy }))\n .filter(({ d }) => (down ? d > 1 : d < -1))\n .sort((a, b) => Math.abs(a.d) - Math.abs(b.d));\n if (cand[0]) select(cand[0].n.id);\n };\n\n // ── the keyboard ─────────────────────────────────────────────────────\n const [help, setHelp] = useState(false);\n const onKeyDownCapture = (e) => {\n if (e.nativeEvent.isComposing) return;\n const target = e.target;\n const k = e.key;\n const mod = e.metaKey || e.ctrlKey;\n const done = () => {\n e.preventDefault();\n e.stopPropagation();\n };\n const cur = treeRef.current;\n const ed = editingRef.current;\n\n // Typing a topic's words.\n if (ed && target?.closest?.(\"[role=textbox]\")) {\n const t = findTopic(cur, ed.id);\n const empty = !String(t?.text ?? \"\").trim();\n const isRoot = ed.id === cur.id;\n if (k === \"Enter\" && !e.shiftKey) {\n done();\n if (mod || isRoot) return finishEdit();\n if (empty && ed.isNew) {\n // An empty new line: step out a level, or stop.\n if (pathTo(cur, ed.id).length >= 3) return restructure(outdentTopic, ed.id, { keepEditing: true });\n return finishEdit();\n }\n finishEdit();\n return addSibling(ed.id);\n }\n if (k === \"Tab\") {\n done();\n if (isRoot) {\n finishEdit();\n return addChild(cur.id);\n }\n if (e.shiftKey) return restructure(outdentTopic, ed.id, { keepEditing: true });\n if (empty && ed.isNew) return restructure(indentTopic, ed.id, { keepEditing: true });\n finishEdit();\n return addChild(ed.id);\n }\n if (k === \"Escape\") {\n done();\n return finishEdit();\n }\n if (k === \"Backspace\" && empty && !isRoot && !(t?.children?.length)) {\n done();\n const prev = neighbourAfterRemove(cur, ed.id, { preferBefore: true });\n setEditing(null);\n if (ed.isNew) {\n setTree(removeTopic(cur, ed.id));\n const past = histRef.current.past;\n if (past[past.length - 1] === ed.before) setHist({ past: past.slice(0, -1), future: histRef.current.future });\n scheduleEmit();\n } else commit(removeTopic(cur, ed.id));\n if (prev && prev !== cur.id) startEdit(prev);\n else select(prev);\n return;\n }\n return;\n }\n if (target?.closest?.(EDITING_TARGET)) return;\n\n const sel = selectedRef.current && findTopic(cur, selectedRef.current) ? selectedRef.current : null;\n const low = k.length === 1 ? k.toLowerCase() : k;\n\n if (k === \"?\" && !mod) return done(), setHelp(true);\n if (mod && low === \"z\" && !readOnly) return done(), e.shiftKey ? redo() : undo();\n if (mod && low === \"y\" && !readOnly) return done(), redo();\n if (k === \"Escape\") {\n if (sel) return done(), select(null);\n setTimeout(hand, 0);\n return;\n }\n if (!sel) {\n if (k.startsWith(\"Arrow\") || k === \"Enter\" || k === \"Home\") return done(), select(cur.id);\n return;\n }\n if (k === \"Home\") return done(), select(cur.id);\n if (k.startsWith(\"Arrow\")) {\n if (mod && (k === \"ArrowUp\" || k === \"ArrowDown\")) return done(), toggleFold(sel, k === \"ArrowUp\");\n if (e.altKey && (k === \"ArrowUp\" || k === \"ArrowDown\")) return done(), !readOnly && restructure((r, id) => shiftTopic(r, id, k === \"ArrowUp\" ? -1 : 1), sel);\n if (mod || e.altKey) return;\n return done(), navigate(k);\n }\n if (readOnly) {\n if (k === \" \" || k === \"Enter\") return done(), toggleFold(sel);\n return;\n }\n if (k === \"Tab\") return done(), e.shiftKey ? restructure(outdentTopic, sel) : addChild(sel);\n if (k === \"Enter\") return done(), addSibling(sel, { before: e.shiftKey });\n if (k === \" \" || k === \"F2\") return done(), startEdit(sel);\n if (k === \"Delete\" || k === \"Backspace\") return done(), remove(sel);\n if (mod && low === \"d\") {\n done();\n const r = duplicateTopic(cur, sel);\n if (r.id) {\n commit(r.root);\n select(r.id);\n }\n return;\n }\n // ⇧1 and ⇧2 stay the canvas's (fit, zoom to selection).\n if (e.shiftKey && (e.code === \"Digit1\" || e.code === \"Digit2\")) return;\n if (!mod && !e.altKey && k.length === 1) return done(), startEdit(sel, { replace: k });\n };\n\n // Copy, cut, paste: a branch as an outline, and an outline (from notes,\n // a doc, an agent) as subtopics of the selection.\n useEffect(() => {\n const here = () => {\n const a = document.activeElement;\n return boxRef.current?.contains(a) && !a?.closest?.(EDITING_TARGET) && !a?.closest?.(\"[role=textbox]\");\n };\n const copy = (e) => {\n const id = selectedRef.current;\n const t = id && findTopic(treeRef.current, id);\n if (!here() || !t) return false;\n e.clipboardData?.setData(\"text/plain\", mindMapToText(t));\n e.preventDefault();\n e.stopPropagation();\n return true;\n };\n const onCopy = (e) => {\n if (copy(e)) say(\"Copied as an outline\");\n };\n const onCut = (e) => {\n if (readOnly) return;\n const id = selectedRef.current;\n if (id && id !== treeRef.current.id && copy(e)) remove(id);\n };\n const onPaste = (e) => {\n if (!here() || readOnly) return;\n const text = e.clipboardData?.getData(\"text/plain\") ?? \"\";\n const topics = outlineToTopics(text).slice(0, 200);\n e.preventDefault();\n e.stopPropagation();\n if (!topics.length) return;\n const cur = treeRef.current;\n const parent = selectedRef.current && findTopic(cur, selectedRef.current) ? selectedRef.current : cur.id;\n let next = cur;\n for (const t of topics) next = insertTopic(next, parent, t);\n commit(next);\n select(topics[0].id);\n say(`${topics.length} topic${topics.length === 1 ? \"\" : \"s\"} pasted`);\n };\n window.addEventListener(\"copy\", onCopy, true);\n window.addEventListener(\"cut\", onCut, true);\n window.addEventListener(\"paste\", onPaste, true);\n return () => {\n window.removeEventListener(\"copy\", onCopy, true);\n window.removeEventListener(\"cut\", onCut, true);\n window.removeEventListener(\"paste\", onPaste, true);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [readOnly]);\n\n // ── the pointer: select, drag a topic onto another ───────────────────\n const [drag, setDrag] = useState(null); // { id, text, x, y }\n const [dropId, setDropId] = useState(null);\n const onTopicPointerDown = useCallback(\n (e, id) => {\n if (editingRef.current && editingRef.current.id !== id) finishEdit();\n select(id);\n const cur = treeRef.current;\n if (readOnly || id === cur.id || e.pointerType !== \"mouse\") return;\n const start = { x: e.clientX, y: e.clientY };\n let active = false;\n let over = null;\n const t = findTopic(cur, id);\n const move = (ev) => {\n if (!active && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < 5) return;\n active = true;\n const host = boxRef.current?.getBoundingClientRect();\n if (!host) return;\n const hit = document\n .elementsFromPoint(ev.clientX, ev.clientY)\n .map((el) => el.closest?.(\"[data-topic]\"))\n .find((el) => el && el.dataset.topic !== id);\n const tid = hit?.dataset.topic ?? null;\n over = tid && !isWithin(treeRef.current, id, tid) && parentOf(treeRef.current, id)?.id !== tid ? tid : null;\n setDropId(over);\n setDrag({ id, text: t?.text || \"Untitled\", x: ev.clientX - host.left, y: ev.clientY - host.top });\n };\n const up = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n window.removeEventListener(\"pointercancel\", up);\n if (!active) return;\n setDrag(null);\n setDropId(null);\n if (!over) return;\n const cur2 = treeRef.current;\n const next = moveTopic(cur2, id, over);\n if (next !== cur2) {\n commit(next);\n select(id);\n const to = findTopic(next, over);\n say(`Moved under ${to?.text || \"Untitled\"}`);\n }\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n window.addEventListener(\"pointercancel\", up);\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [readOnly, finishEdit, select],\n );\n\n // A click on the empty canvas (not a pan) clears the selection.\n const paneDown = useRef(null);\n const onPointerDownCapture = (e) => {\n paneDown.current = e.target?.classList?.contains(\"react-flow__pane\") ? { x: e.clientX, y: e.clientY } : null;\n };\n const onPointerUpCapture = (e) => {\n const p = paneDown.current;\n paneDown.current = null;\n if (!p || Math.hypot(e.clientX - p.x, e.clientY - p.y) > 4) return;\n finishEdit();\n select(null);\n };\n\n // ── the context the topics read ──────────────────────────────────────\n const ctx = useMemo(\n () => ({\n selectedId,\n editingId: editing?.id ?? null,\n dropId,\n dragId: drag?.id ?? null,\n readOnly,\n coarse,\n placeholder,\n onTopicPointerDown,\n startEdit,\n setText,\n finishEdit,\n toggleFold,\n addChild,\n }),\n [selectedId, editing, dropId, drag, readOnly, coarse, placeholder, onTopicPointerDown, startEdit, setText, finishEdit, toggleFold, addChild],\n );\n\n // ── the chrome ───────────────────────────────────────────────────────\n const [view, setView] = useState(\"map\");\n const selTopic = selectedId ? findTopic(tree, selectedId) : null;\n const selIsRoot = selTopic?.id === tree.id;\n const selColor = selectedId ? itemsById.get(selectedId)?.data.color : null;\n const empty = !(tree.children?.length ?? 0);\n const palette = useMemo(() => [...new Set([...colors, ...EXTRA_COLORS])], [colors]);\n const starters = templates ?? mindMapTemplates();\n const hint = editing\n ? [[\"Enter\", \"next topic\"], [\"Tab\", \"subtopic\"], [\"Esc\", \"done\"]]\n : selTopic\n ? readOnly\n ? [[\"Arrows\", \"move\"], [\"Space\", selTopic.collapsed ? \"unfold\" : \"fold\"]]\n : [[\"Tab\", \"subtopic\"], [\"Enter\", \"topic\"], [\"Space\", \"rename\"], [\"⌫\", \"delete\"]]\n : [[\"Click\", \"a topic\"], [\"Drag\", \"to pan\"], [\"?\", \"shortcuts\"]];\n const copyMarkdown = async () => {\n try {\n await navigator.clipboard.writeText(mindMapToMarkdown(treeRef.current));\n toast.success(\"Copied as Markdown\");\n } catch {\n toast.error(\"Couldn't copy\", { description: \"Your browser kept the clipboard closed.\" });\n }\n };\n const sized = /(^|\\s)(h-|min-h-|size-|flex-1|grow)/.test(className ?? \"\");\n\n return (\n <section\n aria-label={label}\n className={cx(\"flex flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground\", className)}\n style={sized ? undefined : { height }}\n >\n <header className=\"flex min-h-12 shrink-0 flex-wrap items-center gap-1 border-b border-border bg-card px-2 py-1.5 text-card-foreground\">\n {!readOnly ? (\n <>\n <Tooltip label={`Undo · ${isMac() ? \"⌘\" : \"Ctrl+\"}Z`}>\n <IconButton icon=\"undo\" label=\"Undo\" size=\"sm\" disabled={!hist.past.length} onClick={undo} />\n </Tooltip>\n <Tooltip label={`Redo · ${isMac() ? \"⇧⌘Z\" : \"Ctrl+Shift+Z\"}`}>\n <IconButton icon=\"redo\" label=\"Redo\" size=\"sm\" disabled={!hist.future.length} onClick={redo} />\n </Tooltip>\n <SaveState save={save} onRetry={() => runSave(treeRef.current)} />\n </>\n ) : (\n <p className=\"px-2 text-sm font-medium\">{clip(tree.text, 48) || \"Mind map\"}</p>\n )}\n <div className=\"flex-1\" />\n {view === \"map\" && !readOnly ? (\n <div role=\"toolbar\" aria-label=\"Topic\" className=\"flex items-center gap-1\">\n <Tooltip label={selTopic && !selIsRoot ? \"Add a topic below · Enter\" : \"Add a main topic\"}>\n <Button size=\"sm\" variant=\"quiet\" icon=\"plus\" onClick={() => (selTopic ? addSibling(selTopic.id) : addChild(tree.id))} aria-label=\"Add topic\">\n <span className=\"max-sm:hidden\">Topic</span>\n </Button>\n </Tooltip>\n <Tooltip label=\"Add a subtopic · Tab\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"git-branch\" onClick={() => addChild(selTopic?.id ?? tree.id)} aria-label=\"Add subtopic\">\n <span className=\"max-sm:hidden\">Subtopic</span>\n </Button>\n </Tooltip>\n <Separator orientation=\"vertical\" className=\"mx-0.5 h-5 max-sm:hidden\" />\n <Popover\n label=\"Branch colour\"\n width=\"auto\"\n className=\"p-1.5\"\n trigger={(p) => (\n <Button {...p} variant=\"quiet\" size=\"sm\" iconOnly aria-label=\"Branch colour\" title=\"Branch colour\" disabled={!selTopic || selIsRoot}>\n <ColorDot color={selColor ?? \"muted\"} />\n </Button>\n )}\n >\n <Palette colors={palette} value={selTopic?.color ?? selColor} swatch=\"dot\" onPick={(c) => selTopic && setColor(selTopic.id, c)} label=\"Branch colour\" />\n {selTopic?.color ? (\n <Button variant=\"quiet\" size=\"sm\" className=\"mt-1 w-full\" onClick={() => setColor(selTopic.id, undefined)}>\n Use the branch's colour\n </Button>\n ) : null}\n </Popover>\n <Tooltip label={selTopic?.collapsed ? \"Unfold · ⌘↓\" : \"Fold · ⌘↑\"}>\n <IconButton\n icon={selTopic?.collapsed ? \"eye\" : \"eye-off\"}\n label={selTopic?.collapsed ? \"Unfold branch\" : \"Fold branch\"}\n size=\"sm\"\n disabled={!selTopic || selIsRoot || !selTopic.children?.length}\n onClick={() => toggleFold(selTopic.id)}\n />\n </Tooltip>\n <Tooltip label=\"Delete · ⌫\">\n <IconButton icon=\"trash\" label=\"Delete topic\" size=\"sm\" disabled={!selTopic || selIsRoot} onClick={() => remove(selTopic.id)} />\n </Tooltip>\n </div>\n ) : null}\n <ToggleGroup\n size=\"sm\"\n className=\"max-sm:hidden\"\n aria-label=\"View\"\n value={view}\n onChange={setView}\n items={[\n { value: \"map\", label: \"Map\", icon: \"git-branch\", iconOnly: true },\n { value: \"outline\", label: \"Outline\", icon: \"list\", iconOnly: true },\n ]}\n />\n <Menu\n label=\"Map actions\"\n items={[\n { label: \"Unfold everything\", icon: \"eye\", onSelect: () => (readOnly ? setTree(setAllCollapsed(treeRef.current, false)) : commit(setAllCollapsed(treeRef.current, false))) },\n { label: \"Fold every branch\", icon: \"eye-off\", onSelect: () => (readOnly ? setTree(setAllCollapsed(treeRef.current, true)) : commit(setAllCollapsed(treeRef.current, true))) },\n { label: \"Fit to screen\", icon: \"maximize\", shortcut: \"⇧1\", onSelect: () => canvas.current?.fitView() },\n { type: \"checkbox\", label: \"Outline view\", checked: view === \"outline\", onCheckedChange: (on) => setView(on ? \"outline\" : \"map\") },\n { type: \"separator\" },\n { label: \"Copy as Markdown\", icon: \"copy\", onSelect: copyMarkdown },\n { label: \"Keyboard shortcuts\", icon: \"keyboard\", shortcut: \"?\", onSelect: () => setHelp(true) },\n ]}\n />\n </header>\n\n <div\n ref={boxRef}\n className=\"relative min-h-0 flex-1\"\n onKeyDownCapture={onKeyDownCapture}\n onPointerDownCapture={onPointerDownCapture}\n onPointerUpCapture={onPointerUpCapture}\n >\n <p className=\"sr-only\" aria-live=\"polite\">\n {announce}\n </p>\n <div className=\"absolute inset-0\" inert={view !== \"map\" || undefined} aria-hidden={view !== \"map\" || undefined}>\n <MindContext.Provider value={ctx}>\n <FlowCanvas\n value={board}\n readOnly\n nodeTypes={NODE_TYPES}\n canvasRef={canvas}\n label={label}\n height=\"100%\"\n bare\n emptyState={false}\n templates={[]}\n describeNode={describeTopic}\n className={String.raw`[&_.react-flow\\_\\_node]:transition-transform [&_.react-flow\\_\\_node]:duration-200 [&_.react-flow\\_\\_node]:ease-out motion-reduce:[&_.react-flow\\_\\_node]:transition-none`}\n />\n </MindContext.Provider>\n </div>\n\n {view === \"map\" && drag ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute z-20 grid -translate-x-1/2 -translate-y-[calc(100%+10px)] justify-items-center gap-1\" style={{ left: drag.x, top: drag.y }}>\n <span className=\"max-w-60 truncate rounded-lg border border-border bg-popover px-3 py-1.5 text-sm font-medium text-popover-foreground shadow-lg\">{clip(drag.text, 40)}</span>\n <span className=\"rounded-md bg-foreground px-2 py-0.5 text-xs text-background\">{dropId ? `Move under “${clip(findTopic(tree, dropId)?.text, 24) || \"Untitled\"}”` : \"Drop on a topic\"}</span>\n </div>\n ) : null}\n\n {view === \"map\" && empty && !readOnly && !editing ? (\n <Starter templates={starters} onPick={(t) => loadMap(typeof t.map === \"function\" ? t.map() : t.map)} onStart={() => addChild(tree.id)} />\n ) : null}\n\n {view === \"map\" ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute bottom-3 left-3 z-10 hidden max-w-[calc(100%-12rem)] flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-border bg-card/90 px-2.5 py-1.5 text-xs text-muted-foreground shadow-xs backdrop-blur-sm md:flex\">\n {hint.map(([key, what]) => (\n <span key={key} className=\"flex items-center gap-1.5 whitespace-nowrap\">\n <Kbd>{key}</Kbd>\n {what}\n </span>\n ))}\n </div>\n ) : null}\n\n {view === \"outline\" ? (\n <OutlineView\n tree={tree}\n items={itemsById}\n onPick={(id) => {\n setView(\"map\");\n // Open the folds on the way down, so the topic can be seen.\n const path = pathTo(treeRef.current, id);\n let next = treeRef.current;\n for (const p of path.slice(0, -1)) if (p.collapsed) next = updateTopic(next, p.id, { collapsed: false });\n if (next !== treeRef.current) (readOnly ? setTree(next) : commit(next));\n select(id);\n }}\n onCopy={copyMarkdown}\n />\n ) : null}\n </div>\n\n <Shortcuts open={help} onClose={() => setHelp(false)} readOnly={readOnly} />\n </section>\n );\n}\n\n// ── pieces ─────────────────────────────────────────────────────────────\n\nconst clip = (s, n = 60) => {\n const t = String(s ?? \"\").replace(/\\s+/g, \" \").trim();\n return t.length > n ? `${t.slice(0, n - 1)}…` : t;\n};\n\nfunction describeTopic(n) {\n if (n.type !== \"topic\") return undefined;\n const d = n.data ?? {};\n return `${d.depth === 0 ? \"Centre\" : `Level ${d.depth}`}: \"${clip(d.text) || \"Untitled\"}\"${d.collapsed ? ` (folded, ${d.hidden} hidden)` : \"\"}`;\n}\n\n/** Where the selection goes when a topic leaves: the next sibling, else\n * the one before, else the parent. */\nfunction neighbourAfterRemove(root, id, { preferBefore = false } = {}) {\n const parent = parentOf(root, id);\n if (!parent) return root.id;\n const kids = parent.children;\n const i = kids.findIndex((c) => c.id === id);\n const order = preferBefore ? [kids[i - 1], kids[i + 1]] : [kids[i + 1], kids[i - 1]];\n return (order.find(Boolean) ?? parent).id;\n}\n\nfunction SaveState({ save, onRetry }) {\n if (save.state === \"error\") {\n return (\n <div role=\"alert\" className=\"flex min-w-0 items-center gap-1 pl-1 text-xs\">\n <Icon name=\"warning\" size={14} className=\"shrink-0 text-destructive\" />\n <span className=\"truncate max-md:sr-only\">{save.error || \"Couldn't save\"}</span>\n <Button variant=\"quiet\" size=\"sm\" onClick={onRetry}>\n Retry\n </Button>\n </div>\n );\n }\n if (save.state === \"idle\") return null;\n const saving = save.state === \"saving\";\n return (\n <p role=\"status\" className=\"flex shrink-0 items-center gap-1 px-1 text-xs text-muted-foreground\">\n <Icon name={saving ? \"loader\" : \"check\"} size={14} className={cx(saving && \"motion-safe:animate-spin\")} />\n <span className=\"max-sm:sr-only\">{saving ? \"Saving…\" : \"Saved\"}</span>\n </p>\n );\n}\n\n/** The first run: how the map works, and a few starting points. */\nfunction Starter({ templates, onPick, onStart }) {\n return (\n <div className=\"pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center md:bottom-14\">\n <div className=\"pointer-events-auto w-full max-w-md rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-lg\">\n <p className=\"text-sm font-semibold\">Start with the big idea</p>\n <p className=\"mt-1 text-sm text-muted-foreground\">\n Double-click the centre to name it. <Kbd>Tab</Kbd> adds a branch, <Kbd>Enter</Kbd> the next one — no mouse needed.\n </p>\n <div className=\"mt-3 flex flex-wrap gap-2\">\n <Button size=\"sm\" variant=\"primary\" icon=\"plus\" onClick={onStart}>\n Add a branch\n </Button>\n {templates.map((t) => (\n <Tooltip key={t.id} label={t.description ?? t.label}>\n <Button size=\"sm\" icon={t.icon} onClick={() => onPick(t)}>\n {t.label}\n </Button>\n </Tooltip>\n ))}\n </div>\n </div>\n </div>\n );\n}\n\n/** The map as a nested list: the reading of it for a screen reader, and\n * a quick way to jump to a topic. */\nfunction OutlineView({ tree, items, onPick, onCopy }) {\n const rows = [];\n const walk = (t, depth, color) => {\n const c = items.get(t.id)?.data.color ?? color;\n rows.push({ t, depth, color: c });\n for (const k of t.children ?? []) walk(k, depth + 1, c);\n };\n walk(tree, 0, null);\n return (\n <div className=\"absolute inset-0 z-10 overflow-y-auto bg-background\">\n <div className=\"mx-auto grid max-w-2xl gap-4 px-4 py-6 sm:px-6\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <div className=\"min-w-0\">\n <h3 className=\"truncate text-base font-semibold\">{tree.text || \"Untitled\"}</h3>\n <p className=\"text-sm text-muted-foreground\">\n {countTopics(tree)} topic{countTopics(tree) === 1 ? \"\" : \"s\"}. Choose one to find it on the map.\n </p>\n </div>\n <Button size=\"sm\" icon=\"copy\" onClick={onCopy}>\n Copy as Markdown\n </Button>\n </div>\n {rows.length > 1 ? (\n <ul className=\"grid gap-0.5\" aria-label=\"Outline\">\n {rows.slice(1).map(({ t, depth, color }) => (\n <li key={t.id} style={{ paddingLeft: (depth - 1) * 20 }}>\n <Button variant=\"quiet\" size=\"sm\" align=\"start\" className=\"w-full min-w-0\" onClick={() => onPick(t.id)}>\n <ColorDot color={color ?? \"muted\"} className={depth > 1 ? \"size-2\" : \"size-2.5\"} />\n <span className={cx(\"truncate\", depth === 1 ? \"font-medium\" : \"font-normal\")}>{t.text || \"Untitled\"}</span>\n {t.collapsed && t.children?.length ? <span className=\"ml-auto shrink-0 text-xs text-muted-foreground tabular-nums\">{countTopics(t)} folded</span> : null}\n </Button>\n </li>\n ))}\n </ul>\n ) : (\n <div className=\"grid justify-items-center gap-2 rounded-lg border border-dashed border-border px-4 py-10 text-center\">\n <p className=\"text-sm text-muted-foreground\">Only the centre so far. Branches you add show up here as a list.</p>\n </div>\n )}\n </div>\n </div>\n );\n}\n\nconst KEYS = [\n [\"Tab\", \"Add a subtopic\"],\n [\"Enter\", \"Add the next topic (⇧ above)\"],\n [\"Space\", \"Rename (or just start typing)\"],\n [\"⌫\", \"Delete the topic and its branch\"],\n [\"← → ↑ ↓\", \"Move around the map\"],\n [\"⌥↑ ⌥↓\", \"Move the topic up or down\"],\n [\"⇧Tab\", \"Move the topic up a level\"],\n [\"⌘↑ ⌘↓\", \"Fold, unfold\"],\n [\"⌘D\", \"Duplicate the branch\"],\n [\"⌘C ⌘V\", \"Copy a branch, paste an outline\"],\n [\"⌘Z\", \"Undo (⇧⌘Z redo)\"],\n [\"⇧1\", \"Fit the map to the screen\"],\n];\nconst TYPING = [\n [\"Enter\", \"Next topic (an empty one steps out a level)\"],\n [\"Tab\", \"Subtopic (an empty one moves in a level)\"],\n [\"⇧Enter\", \"A new line in the words\"],\n [\"Esc\", \"Done\"],\n];\n\nfunction Shortcuts({ open, onClose, readOnly }) {\n const mac = isMac();\n const key = (k) => (mac ? k : k.replace(/⌘/g, \"Ctrl+\").replace(/⌥/g, \"Alt+\").replace(/⇧/g, \"Shift+\"));\n const rows = readOnly ? [[\"← → ↑ ↓\", \"Move around the map\"], [\"Space\", \"Fold, unfold\"], [\"⇧1\", \"Fit the map to the screen\"]] : KEYS;\n const list = (items) => (\n <dl className=\"grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2 text-sm\">\n {items.map(([k, what]) => (\n <div key={k} className=\"contents\">\n <dt className=\"justify-self-start\">\n <Kbd>{key(k)}</Kbd>\n </dt>\n <dd className=\"text-muted-foreground\">{what}</dd>\n </div>\n ))}\n </dl>\n );\n return (\n <Sheet open={open} onClose={onClose} title=\"Keyboard shortcuts\" description=\"A mind map you can build without the mouse.\">\n <div className=\"grid gap-6\">\n <section className=\"grid gap-3\">\n <h3 className=\"text-sm font-medium\">With a topic selected</h3>\n {list(rows)}\n </section>\n {!readOnly ? (\n <section className=\"grid gap-3\">\n <h3 className=\"text-sm font-medium\">While typing a topic</h3>\n {list(TYPING)}\n </section>\n ) : null}\n </div>\n </Sheet>\n );\n}\n"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"path": "registry/blocks/mind-map/topic.jsx",
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
"path": "registry/blocks/mind-map/tree.js",
|
|
33
33
|
"type": "registry:component",
|
|
34
34
|
"target": "@components/makersclaw/blocks/mind-map/tree.js",
|
|
35
|
-
"content": "// The mind map's data and its layout — plain functions, no React, no DOM.\n// They run anywhere (the server, an agent's tool, a test).\n//\n// A map is one tree of topics:\n//\n// { id: \"root\", text: \"Launch plan\", children: [\n// { id: \"a\", text: \"Research\", side: \"right\", color: \"chart-3\", children: [...] },\n// { id: \"b\", text: \"Pricing\", collapsed: true, children: [...] },\n// ] }\n//\n// id any string, unique in the map\n// text the words (a newline is allowed)\n// children the subtopics, in order\n// collapsed true: the branch is folded (its subtopics hidden)\n// color a colour TOKEN (\"chart-3\"), the branch's hue; subtopics\n// inherit it. Unset on a main branch: the next in the palette\n// side \"left\" | \"right\", only on main branches (the root's\n// children): which side of the centre it grows on. Unset:\n// balanced by size\n// note any extra text you keep with it (shown nowhere by the map)\n//\n// Every operation returns a NEW tree (the old one is left as it was), so\n// a tree is safe to keep for undo.\n\n/** The branch colours, in order: chart hues, which every theme derives\n * from its own primary. The root wears the primary itself. */\nexport const BRANCH_COLORS = [\"chart-2\", \"chart-3\", \"chart-4\", \"chart-5\", \"chart-1\"];\n\nlet seq = 0;\n/** A fresh topic id: `t-lx3k9f2a`. */\nexport function topicId() {\n seq = (seq + 1) % 1296;\n return `t-${Date.now().toString(36)}${seq.toString(36).padStart(2, \"0\")}${Math.floor(Math.random() * 1296)\n .toString(36)\n .padStart(2, \"0\")}`;\n}\n\n/** A new topic. */\nexport function createTopic(text = \"\", extra) {\n return { id: topicId(), text, children: [], ...(extra ?? {}) };\n}\n\nconst isObj = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v);\n\n/**\n * Anything map-shaped → a clean tree: an object with `text`/`children`\n * (also `title`/`label`/`name` for the words and `topics`/`items` for the\n * children), an outline string (see outlineToMindMap), or nothing (a\n * fresh map). Missing and repeated ids are made unique. Never throws.\n */\nexport function normalizeMap(value, { rootText = \"Central topic\" } = {}) {\n if (typeof value === \"string\") return outlineToMindMap(value, { rootText });\n const seen = new Set();\n const walk = (t, depth) => {\n const text = typeof t?.text === \"string\" ? t.text : String(t?.title ?? t?.label ?? t?.name ?? \"\");\n let id = t?.id != null && t.id !== \"\" ? String(t.id) : depth === 0 ? \"root\" : topicId();\n while (seen.has(id)) id = topicId();\n seen.add(id);\n const kids = Array.isArray(t?.children) ? t.children : Array.isArray(t?.topics) ? t.topics : Array.isArray(t?.items) ? t.items : [];\n const out = { ...t, id, text, children: kids.filter((k) => isObj(k) || typeof k === \"string\").map((k) => walk(typeof k === \"string\" ? { text: k } : k, depth + 1)) };\n delete out.topics;\n delete out.items;\n if (depth !== 1) delete out.side;\n else if (out.side !== \"left\" && out.side !== \"right\") delete out.side;\n if (!out.collapsed) delete out.collapsed;\n if (!out.color) delete out.color;\n return out;\n };\n if (!isObj(value)) return { id: \"root\", text: rootText, children: [] };\n return walk(value, 0);\n}\n\n// ── reading ──────────────────────────────────────────────────────────────\n\n/** Every topic, depth first, with its parent and depth. */\nexport function flattenMap(root) {\n const out = [];\n const walk = (t, parent, depth, index) => {\n out.push({ topic: t, parent, depth, index });\n (t.children ?? []).forEach((c, i) => walk(c, t, depth + 1, i));\n };\n if (root) walk(root, null, 0, 0);\n return out;\n}\n\n/** The topic with this id, or null. */\nexport function findTopic(root, id) {\n if (!root) return null;\n if (root.id === id) return root;\n for (const c of root.children ?? []) {\n const hit = findTopic(c, id);\n if (hit) return hit;\n }\n return null;\n}\n\n/** [root, …, the topic] — the path down to it, or [] when it is not there. */\nexport function pathTo(root, id) {\n if (!root) return [];\n if (root.id === id) return [root];\n for (const c of root.children ?? []) {\n const p = pathTo(c, id);\n if (p.length) return [root, ...p];\n }\n return [];\n}\n\n/** The parent of a topic (null for the root or a stranger). */\nexport function parentOf(root, id) {\n const p = pathTo(root, id);\n return p.length > 1 ? p[p.length - 2] : null;\n}\n\n/** How many topics sit under this one, at every depth. */\nexport function countTopics(topic) {\n let n = 0;\n for (const c of topic?.children ?? []) n += 1 + countTopics(c);\n return n;\n}\n\n/** Is `id` the topic `ancestor` or anywhere under it? */\nexport function isWithin(root, ancestorId, id) {\n const a = findTopic(root, ancestorId);\n return Boolean(a && findTopic(a, id));\n}\n\n// ── changing ─────────────────────────────────────────────────────────────\n\n/** A copy of the tree with one topic replaced by fn(topic). */\nexport function mapTopic(root, id, fn) {\n if (!root) return root;\n if (root.id === id) return fn(root);\n let changed = false;\n const kids = (root.children ?? []).map((c) => {\n const next = mapTopic(c, id, fn);\n if (next !== c) changed = true;\n return next;\n });\n return changed ? { ...root, children: kids } : root;\n}\n\n/** Merge a patch into one topic (`{ text }`, `{ color }`, `{ collapsed }`…). */\nexport function updateTopic(root, id, patch) {\n return mapTopic(root, id, (t) => {\n const next = { ...t, ...(typeof patch === \"function\" ? patch(t) : patch) };\n for (const k of [\"color\", \"collapsed\", \"side\", \"note\"]) if (next[k] === undefined || next[k] === false || next[k] === \"\") delete next[k];\n return next;\n });\n}\n\n// Main branches keep the side they are on once the map changes around\n// them, so adding one never flips the others across the centre.\nfunction pinSides(root) {\n const kids = root.children ?? [];\n if (kids.every((c) => c.side)) return root;\n const sides = splitSides(root);\n return { ...root, children: kids.map((c) => (c.side ? c : { ...c, side: sides.get(c.id) })) };\n}\n\n/** Put `topic` under `parentId` at `index` (default: last). A new main\n * branch goes on the lighter side unless it names one. */\nexport function insertTopic(root, parentId, topic, index) {\n const t = topic ?? createTopic();\n if (parentId === root.id) {\n const pinned = pinSides(root);\n const kids = [...(pinned.children ?? [])];\n let side = t.side;\n if (!side) {\n const l = kids.filter((c) => c.side === \"left\").reduce((n, c) => n + 1 + visibleCount(c), 0);\n const r = kids.filter((c) => c.side !== \"left\").reduce((n, c) => n + 1 + visibleCount(c), 0);\n side = l < r ? \"left\" : \"right\";\n }\n const at = index === undefined ? kids.length : Math.max(0, Math.min(kids.length, index));\n kids.splice(at, 0, { ...t, side });\n return { ...pinned, children: kids };\n }\n return mapTopic(root, parentId, (p) => {\n const kids = [...(p.children ?? [])];\n const { side: _s, ...rest } = t;\n const at = index === undefined ? kids.length : Math.max(0, Math.min(kids.length, index));\n kids.splice(at, 0, rest);\n const next = { ...p, children: kids };\n delete next.collapsed;\n return next;\n });\n}\n\nfunction visibleCount(t) {\n if (t.collapsed) return 0;\n let n = 0;\n for (const c of t.children ?? []) n += 1 + visibleCount(c);\n return n;\n}\n\n/** A new topic next to `id` (after it, or before with `before`). On the\n * root there is no next to: it becomes a child. */\nexport function insertSibling(root, id, topic, { before = false } = {}) {\n const parent = parentOf(root, id);\n if (!parent) return insertTopic(root, root.id, topic);\n const i = parent.children.findIndex((c) => c.id === id);\n const me = parent.children[i];\n const t = parent.id === root.id && me?.side ? { ...topic, side: topic.side ?? me.side } : topic;\n return insertTopic(root, parent.id, t, before ? i : i + 1);\n}\n\n/** Take a topic (and its branch) out. The root stays. */\nexport function removeTopic(root, id) {\n if (!root || root.id === id) return root;\n const parent = parentOf(root, id);\n if (!parent) return root;\n return mapTopic(root, parent.id, (p) => ({ ...p, children: p.children.filter((c) => c.id !== id) }));\n}\n\n/** Move a topic (with its branch) under `parentId` at `index` (default\n * last). Refused (the same tree back) into its own branch. */\nexport function moveTopic(root, id, parentId, index) {\n if (id === root.id || isWithin(root, id, parentId)) return root;\n const t = findTopic(root, id);\n if (!t) return root;\n const from = parentOf(root, id);\n let at = index;\n if (from?.id === parentId && at !== undefined) {\n const was = from.children.findIndex((c) => c.id === id);\n if (was < at) at -= 1;\n }\n const without = removeTopic(root, id);\n const moved = { ...t };\n if (parentId !== root.id) delete moved.side;\n else if (from?.id !== root.id) delete moved.side;\n return insertTopic(without, parentId, moved, at);\n}\n\n/** Swap a topic with its neighbour: `delta` -1 (up) or +1 (down). A main\n * branch trades places with the next one on its own side. */\nexport function shiftTopic(root, id, delta) {\n const parent = parentOf(root, id);\n if (!parent) return root;\n const base = parent.id === root.id ? pinSides(root) : parent;\n const kids = base.children;\n const i = kids.findIndex((c) => c.id === id);\n const sameSide = (c) => parent.id !== root.id || (c.side ?? \"right\") === (kids[i].side ?? \"right\");\n let j = i + delta;\n while (j >= 0 && j < kids.length && !sameSide(kids[j])) j += delta;\n if (j < 0 || j >= kids.length) return root;\n const next = [...kids];\n [next[i], next[j]] = [next[j], next[i]];\n if (parent.id === root.id) return { ...base, children: next };\n return mapTopic(root, parent.id, (p) => ({ ...p, children: next }));\n}\n\n/** Tab on an outline: the topic becomes the last child of the topic\n * before it. Nothing before it: the same tree back. */\nexport function indentTopic(root, id) {\n const parent = parentOf(root, id);\n if (!parent) return root;\n const kids = parent.id === root.id ? pinSides(root).children : parent.children;\n const i = kids.findIndex((c) => c.id === id);\n const me = kids[i];\n let j = i - 1;\n if (parent.id === root.id) while (j >= 0 && (kids[j].side ?? \"right\") !== (me.side ?? \"right\")) j -= 1;\n if (j < 0) return root;\n return moveTopic(root, id, kids[j].id);\n}\n\n/** Shift+Tab: the topic moves up a level, to just after its parent. */\nexport function outdentTopic(root0, id) {\n const root = pinSides(root0);\n const path = pathTo(root, id);\n if (path.length < 3) return root0;\n const parent = path[path.length - 2];\n const grand = path[path.length - 3];\n const at = grand.children.findIndex((c) => c.id === parent.id) + 1;\n const t = findTopic(root, id);\n const moved = grand.id === root.id ? { ...t, side: parent.side } : t;\n const without = removeTopic(root, id);\n return insertTopic(without, grand.id, moved, at);\n}\n\n/** A copy of a branch with fresh ids, placed right after it. */\nexport function duplicateTopic(root, id) {\n const t = findTopic(root, id);\n if (!t || id === root.id) return { root, id: null };\n const copy = (x) => ({ ...x, id: topicId(), children: (x.children ?? []).map(copy) });\n const c = copy(t);\n return { root: insertSibling(root, id, c), id: c.id };\n}\n\n/** Fold (or unfold) every branch that has subtopics, the root aside. */\nexport function setAllCollapsed(root, collapsed) {\n const walk = (t, depth) => {\n const kids = (t.children ?? []).map((c) => walk(c, depth + 1));\n const next = { ...t, children: kids };\n if (depth > 0 && collapsed && kids.length) next.collapsed = true;\n else delete next.collapsed;\n return next;\n };\n return walk(root, 0);\n}\n\n// ── outline in, outline out ──────────────────────────────────────────────\n\n/**\n * An indented outline → topics (a forest: an array of top-level topics).\n * Bullets (-, *, •, 1.), markdown headings (# is the top), tabs or spaces;\n * blank lines skipped. What an agent writes, or a list pasted from notes.\n */\nexport function outlineToTopics(text) {\n const lines = String(text ?? \"\")\n .replace(/\\r\\n?/g, \"\\n\")\n .split(\"\\n\")\n .filter((l) => l.trim());\n if (!lines.length) return [];\n const rows = lines.map((l) => {\n const heading = /^\\s*(#{1,6})\\s+(.*)$/.exec(l);\n if (heading) return { level: heading[1].length - 7, text: heading[2].trim() };\n const lead = /^[\\t ]*/.exec(l)[0].replace(/\\t/g, \" \").length;\n const body = l.trim().replace(/^(?:[-*+•]|\\d+[.)])\\s+/, \"\").replace(/^\\[[ xX]\\]\\s+/, \"\");\n return { level: lead, text: body.trim() };\n });\n // Headings sit above every indented line; indents rank by their width.\n const levels = [...new Set(rows.map((r) => r.level))].sort((a, b) => a - b);\n const rank = new Map(levels.map((l, i) => [l, i]));\n const forest = [];\n const stack = [];\n for (const r of rows) {\n const depth = rank.get(r.level);\n const t = createTopic(r.text);\n while (stack.length && stack[stack.length - 1].depth >= depth) stack.pop();\n if (stack.length) stack[stack.length - 1].topic.children.push(t);\n else forest.push(t);\n stack.push({ depth, topic: t });\n }\n return forest;\n}\n\n/** An outline → a map. One top line: it is the centre. Several: they\n * hang off a centre called `rootText`. */\nexport function outlineToMindMap(text, { rootText = \"Central topic\" } = {}) {\n const forest = outlineToTopics(text);\n if (forest.length === 1) return { ...forest[0], id: \"root\" };\n return { id: \"root\", text: rootText, children: forest };\n}\n\n/** The map as an indented outline, two spaces a level — for an agent, a\n * clipboard, a search column. Folded branches are included. */\nexport function mindMapToText(topic) {\n const out = [];\n const walk = (t, depth) => {\n out.push(`${\" \".repeat(depth)}${depth ? \"- \" : \"\"}${String(t.text ?? \"\").replace(/\\n/g, \" \") || \"Untitled\"}`);\n for (const c of t.children ?? []) walk(c, depth + 1);\n };\n if (topic) walk(topic, 0);\n return out.join(\"\\n\");\n}\n\n/** The map as Markdown: the centre as a heading, the branches as a\n * nested list. */\nexport function mindMapToMarkdown(root) {\n if (!root) return \"\";\n const lines = [`# ${String(root.text ?? \"\").replace(/\\n/g, \" \") || \"Untitled\"}`, \"\"];\n const walk = (t, depth) => {\n lines.push(`${\" \".repeat(depth)}- ${String(t.text ?? \"\").replace(/\\n/g, \" \") || \"Untitled\"}`);\n for (const c of t.children ?? []) walk(c, depth + 1);\n };\n for (const c of root.children ?? []) walk(c, 0);\n return lines.join(\"\\n\");\n}\n\n// ── layout ───────────────────────────────────────────────────────────────\n\n// Type and box per level, in px. The node draws with exactly these, so\n// what is laid out is what is shown. level 0 = the centre, 1 = a main\n// branch (a tinted pill), 2+ = a subtopic (words over a coloured line).\nexport const LEVELS = [\n { font: 18, weight: 600, line: 26, padX: 20, padY: 12, min: 120, max: 280, gap: 56 },\n { font: 14, weight: 500, line: 20, padX: 14, padY: 8, min: 64, max: 220, gap: 36 },\n { font: 14, weight: 400, line: 20, padX: 6, padY: 5, min: 44, max: 240, gap: 28 },\n];\nexport const levelOf = (depth) => LEVELS[Math.min(depth, LEVELS.length - 1)];\nconst SIBLING_GAP = [0, 20, 8];\nconst siblingGap = (depth) => SIBLING_GAP[Math.min(depth, SIBLING_GAP.length - 1)];\n/** Room left under a subtopic for its coloured line. */\nexport const UNDERLINE = 2;\n\n/** A width guess without a DOM: average glyph widths for a UI sans. */\nexport function estimateText(text, { font = 14, weight = 400 } = {}) {\n let w = 0;\n for (const ch of String(text ?? \"\")) w += /[A-Z]/.test(ch) ? 0.66 : /[mwMW]/.test(ch) ? 0.8 : /[ilj.,:;'!|]/.test(ch) ? 0.28 : ch === \" \" ? 0.28 : 0.54;\n return w * font * (weight >= 600 ? 1.05 : weight >= 500 ? 1.02 : 1);\n}\n\n/** Words wrapped at `max` px → { width, lines }. */\nexport function wrapText(text, max, measure, style) {\n const paras = String(text ?? \"\").split(\"\\n\");\n let width = 0;\n let lines = 0;\n const space = measure(\" \", style);\n for (const para of paras) {\n const words = para.split(/\\s+/).filter(Boolean);\n if (!words.length) {\n lines += 1;\n continue;\n }\n let line = 0;\n for (const w of words) {\n const ww = measure(w, style);\n if (line && line + space + ww > max) {\n width = Math.max(width, line);\n lines += 1;\n line = 0;\n }\n if (ww > max) {\n // One word longer than the line: it breaks where it must.\n const extra = Math.floor(ww / max);\n lines += extra;\n width = max;\n line = ww - extra * max;\n continue;\n }\n line = line ? line + space + ww : ww;\n }\n width = Math.max(width, line);\n lines += 1;\n }\n return { width: Math.min(max, width), lines: Math.max(1, lines) };\n}\n\n/** The box a topic needs at a depth: { width, height }. `placeholder`\n * sizes an empty topic. */\nexport function topicSize(text, depth, measure = estimateText, placeholder = \"New topic\") {\n const L = levelOf(depth);\n const style = { font: L.font, weight: L.weight };\n const words = String(text ?? \"\").trim() ? text : placeholder;\n const { width, lines } = wrapText(words, L.max - L.padX * 2, measure, style);\n // +2: a browser wraps a hair sooner than the sum of the words' widths.\n const w = Math.max(L.min, Math.ceil(width + 2) + L.padX * 2);\n const h = lines * L.line + L.padY * 2 + (depth >= 2 ? UNDERLINE : 0);\n return { width: w, height: h };\n}\n\n/** Which side each main branch grows on: its own `side`, else split in\n * order so the two sides hold about as many rows. Counted in topics, not\n * pixels, so the answer never depends on the font. Map(id → side). */\nexport function splitSides(root) {\n const kids = root?.children ?? [];\n const rows = (t) => (t.collapsed || !t.children?.length ? 1 : t.children.reduce((n, c) => n + rows(c), 0));\n const w = kids.map(rows);\n const out = new Map();\n let L = 0;\n let R = 0;\n const free = [];\n kids.forEach((c, i) => {\n if (c.side === \"left\") (L += w[i]), out.set(c.id, \"left\");\n else if (c.side === \"right\") (R += w[i]), out.set(c.id, \"right\");\n else free.push(i);\n });\n // The first k free branches go right, the rest left: the k that leaves\n // the two sides closest wins (a tie: more on the right).\n let best = 0;\n let bestDiff = Infinity;\n const total = free.reduce((n, i) => n + w[i], 0);\n let acc = 0;\n for (let k = 0; k <= free.length; k++) {\n const diff = Math.abs(R + acc - (L + total - acc));\n if (diff <= bestDiff) {\n bestDiff = diff;\n best = k;\n }\n if (k < free.length) acc += w[free[k]];\n }\n free.forEach((i, n) => out.set(kids[i].id, n < best ? \"right\" : \"left\"));\n return out;\n}\n\n/**\n * Where every visible topic goes. The centre at (0, 0); main branches\n * on both sides, balanced; each branch a tidy tree growing outwards, a\n * topic centred on its subtopics. Folded branches stop at the fold.\n *\n * measure(text, { font, weight }) → px (default: estimateText)\n * colors the branch palette (default BRANCH_COLORS)\n *\n * Returns { items, bounds }. Each item: { id, topic, parentId, depth,\n * side (\"root\" | \"left\" | \"right\"), color, x, y, width, height (top-left\n * box), hidden (topics folded away under it), from / to (where the line\n * from the parent leaves and arrives, board px) }.\n */\nexport function layoutMindMap(root, { measure = estimateText, colors = BRANCH_COLORS } = {}) {\n if (!root) return { items: [], bounds: { x: 0, y: 0, width: 0, height: 0 } };\n const sizes = new Map();\n const sizeOf = (t, depth) => {\n const k = t.id;\n if (!sizes.has(k)) sizes.set(k, topicSize(t.text, depth, measure));\n return sizes.get(k);\n };\n const block = new Map();\n const blockOf = (t, depth) => {\n if (block.has(t.id)) return block.get(t.id);\n const own = sizeOf(t, depth).height;\n let h = own;\n if (!t.collapsed && t.children?.length) {\n const sum = t.children.reduce((n, c) => n + blockOf(c, depth + 1), 0) + siblingGap(depth + 1) * (t.children.length - 1);\n h = Math.max(own, sum);\n }\n block.set(t.id, h);\n return h;\n };\n\n const items = [];\n const rs = sizeOf(root, 0);\n const rootItem = { id: root.id, topic: root, parentId: null, depth: 0, side: \"root\", color: null, x: -rs.width / 2, y: -rs.height / 2, width: rs.width, height: rs.height, hidden: root.collapsed ? countTopics(root) : 0 };\n items.push(rootItem);\n\n // Where a line leaves a topic towards its children on `side`, and where\n // a line arrives at it.\n const outAnchor = (it, side) => {\n const dir = side === \"left\" ? -1 : 1;\n const x = dir > 0 ? it.x + it.width : it.x;\n const y = it.depth >= 2 ? it.y + it.height - UNDERLINE / 2 : it.y + it.height / 2;\n return { x, y, dir };\n };\n const inAnchor = (it) => {\n const x = it.side === \"left\" ? it.x + it.width : it.x;\n const y = it.depth >= 2 ? it.y + it.height - UNDERLINE / 2 : it.y + it.height / 2;\n return { x, y };\n };\n\n const place = (t, parentItem, depth, side, top, color) => {\n const s = sizeOf(t, depth);\n const b = blockOf(t, depth);\n const from = outAnchor(parentItem, side);\n const gap = levelOf(depth - 1).gap;\n const cy = top + b / 2;\n const x = side === \"left\" ? from.x - gap - s.width : from.x + gap;\n // A subtopic's line is its foot: centre the words so the line sits\n // at the middle of its children.\n const y = cy - s.height / 2;\n const it = { id: t.id, topic: t, parentId: parentItem.id, depth, side, color, x, y, width: s.width, height: s.height, hidden: t.collapsed ? countTopics(t) : 0 };\n it.from = { x: from.x, y: from.y };\n it.to = inAnchor(it);\n items.push(it);\n if (!t.collapsed && t.children?.length) {\n const sum = t.children.reduce((n, c) => n + blockOf(c, depth + 1), 0) + siblingGap(depth + 1) * (t.children.length - 1);\n let y2 = cy - sum / 2;\n for (const c of t.children) {\n place(c, it, depth + 1, side, y2, c.color ?? color);\n y2 += blockOf(c, depth + 1) + siblingGap(depth + 1);\n }\n }\n };\n\n if (!root.collapsed) {\n const sides = splitSides(root);\n const kids = root.children ?? [];\n for (const side of [\"right\", \"left\"]) {\n const mine = kids.map((c, i) => ({ c, i })).filter(({ c }) => sides.get(c.id) === side);\n if (!mine.length) continue;\n const sum = mine.reduce((n, { c }) => n + blockOf(c, 1), 0) + siblingGap(1) * (mine.length - 1);\n let y = -sum / 2;\n for (const { c, i } of mine) {\n place(c, rootItem, 1, side, y, c.color ?? colors[i % colors.length]);\n y += blockOf(c, 1) + siblingGap(1);\n }\n }\n }\n\n let x1 = Infinity;\n let y1 = Infinity;\n let x2 = -Infinity;\n let y2 = -Infinity;\n for (const it of items) {\n x1 = Math.min(x1, it.x);\n y1 = Math.min(y1, it.y);\n x2 = Math.max(x2, it.x + it.width);\n y2 = Math.max(y2, it.y + it.height);\n }\n return { items, bounds: { x: x1, y: y1, width: x2 - x1, height: y2 - y1 } };\n}\n\n/** The map as a FlowCanvas board: one `topic` node per visible topic,\n * laid out (layoutMindMap), no connectors (each topic draws the line to\n * its parent). What MindMap hands the canvas. */\nexport function mindMapToBoard(root, opts) {\n const { items } = layoutMindMap(root, opts);\n const round = (n) => Math.round(n * 100) / 100;\n return {\n nodes: items.map((it) => ({\n id: it.id,\n type: \"topic\",\n position: { x: round(it.x), y: round(it.y) },\n width: Math.round(it.width),\n height: Math.round(it.height),\n draggable: false,\n connectable: false,\n data: {\n text: it.topic.text,\n depth: it.depth,\n side: it.side,\n color: it.color,\n kids: it.topic.children?.length ?? 0,\n collapsed: Boolean(it.topic.collapsed),\n hidden: it.hidden,\n link: it.from ? { x1: round(it.from.x - it.x), y1: round(it.from.y - it.y), x2: round(it.to.x - it.x), y2: round(it.to.y - it.y) } : null,\n },\n })),\n edges: [],\n };\n}\n\n/** The curve from a parent to a child, in the child's own coordinates. */\nexport function branchPath({ x1, y1, x2, y2 }) {\n const dx = (x2 - x1) * 0.55;\n return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;\n}\n\n// ── starters ─────────────────────────────────────────────────────────────\n\nconst t = (text, children = []) => ({ text, children: children.map((c) => (typeof c === \"string\" ? t(c) : c)) });\n\n/** What an empty map offers. Each `map()` makes a fresh tree. */\nexport function mindMapTemplates() {\n return [\n {\n id: \"project\",\n label: \"Project plan\",\n icon: \"flag\",\n description: \"Goals, scope, people, risks\",\n map: () => normalizeMap(t(\"Project\", [t(\"Goals\", [\"Why now\", \"What success looks like\"]), t(\"Scope\", [\"In\", \"Out\"]), t(\"People\", [\"Owner\", \"Helpers\"]), t(\"Milestones\"), t(\"Risks\")])),\n },\n {\n id: \"brainstorm\",\n label: \"Brainstorm\",\n icon: \"lightbulb\",\n description: \"One question, ideas around it\",\n map: () => normalizeMap(t(\"How might we…?\", [\"Idea\", \"Idea\", \"Idea\", \"Idea\"])),\n },\n {\n id: \"meeting\",\n label: \"Meeting notes\",\n icon: \"message-square\",\n description: \"Agenda, decisions, actions\",\n map: () => normalizeMap(t(\"Meeting\", [t(\"Agenda\"), t(\"Notes\"), t(\"Decisions\"), t(\"Actions\")])),\n },\n ];\n}\n"
|
|
35
|
+
"content": "// The mind map's data and its layout — plain functions, no React, no DOM.\n// They run anywhere (the server, an agent's tool, a test).\n//\n// A map is one tree of topics:\n//\n// { id: \"root\", text: \"Launch plan\", children: [\n// { id: \"a\", text: \"Research\", side: \"right\", color: \"chart-3\", children: [...] },\n// { id: \"b\", text: \"Pricing\", collapsed: true, children: [...] },\n// ] }\n//\n// id any string, unique in the map\n// text the words (a newline is allowed)\n// children the subtopics, in order\n// collapsed true: the branch is folded (its subtopics hidden)\n// color a colour TOKEN (\"chart-3\"), the branch's hue; subtopics\n// inherit it. Unset on a main branch: the next in the palette\n// side \"left\" | \"right\", only on main branches (the root's\n// children): which side of the centre it grows on. Unset:\n// balanced by size\n// note any extra text you keep with it (shown nowhere by the map)\n//\n// Every operation returns a NEW tree (the old one is left as it was), so\n// a tree is safe to keep for undo.\n\n/** The branch colours, in order: chart hues, which every theme derives\n * from its own primary. The root wears the primary itself. */\nexport const BRANCH_COLORS = [\"chart-2\", \"chart-3\", \"chart-4\", \"chart-5\", \"chart-1\"];\n\nlet seq = 0;\n/** A fresh topic id: `t-lx3k9f2a`. */\nexport function topicId() {\n seq = (seq + 1) % 1296;\n return `t-${Date.now().toString(36)}${seq.toString(36).padStart(2, \"0\")}${Math.floor(Math.random() * 1296)\n .toString(36)\n .padStart(2, \"0\")}`;\n}\n\n/** A new topic. */\nexport function createTopic(text = \"\", extra) {\n return { id: topicId(), text, children: [], ...(extra ?? {}) };\n}\n\nconst isObj = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v);\n\n/**\n * Anything map-shaped → a clean tree: an object with `text`/`children`\n * (also `title`/`label`/`name` for the words and `topics`/`items` for the\n * children), an outline string (see outlineToMindMap), or nothing (a\n * fresh map). Missing and repeated ids are made unique. Never throws.\n */\nexport function normalizeMap(value, { rootText = \"Central topic\" } = {}) {\n if (typeof value === \"string\") return outlineToMindMap(value, { rootText });\n const seen = new Set();\n const walk = (t, depth) => {\n const text = typeof t?.text === \"string\" ? t.text : String(t?.title ?? t?.label ?? t?.name ?? \"\");\n let id = t?.id != null && t.id !== \"\" ? String(t.id) : depth === 0 ? \"root\" : topicId();\n while (seen.has(id)) id = topicId();\n seen.add(id);\n const kids = Array.isArray(t?.children) ? t.children : Array.isArray(t?.topics) ? t.topics : Array.isArray(t?.items) ? t.items : [];\n const out = { ...t, id, text, children: kids.filter((k) => isObj(k) || typeof k === \"string\").map((k) => walk(typeof k === \"string\" ? { text: k } : k, depth + 1)) };\n delete out.topics;\n delete out.items;\n if (depth !== 1) delete out.side;\n else if (out.side !== \"left\" && out.side !== \"right\") delete out.side;\n if (!out.collapsed) delete out.collapsed;\n if (!out.color) delete out.color;\n return out;\n };\n if (!isObj(value)) return { id: \"root\", text: rootText, children: [] };\n return walk(value, 0);\n}\n\n// ── reading ──────────────────────────────────────────────────────────────\n\n/** Every topic, depth first, with its parent and depth. */\nexport function flattenMap(root) {\n const out = [];\n const walk = (t, parent, depth, index) => {\n out.push({ topic: t, parent, depth, index });\n (t.children ?? []).forEach((c, i) => walk(c, t, depth + 1, i));\n };\n if (root) walk(root, null, 0, 0);\n return out;\n}\n\n/** The topic with this id, or null. */\nexport function findTopic(root, id) {\n if (!root) return null;\n if (root.id === id) return root;\n for (const c of root.children ?? []) {\n const hit = findTopic(c, id);\n if (hit) return hit;\n }\n return null;\n}\n\n/** [root, …, the topic] — the path down to it, or [] when it is not there. */\nexport function pathTo(root, id) {\n if (!root) return [];\n if (root.id === id) return [root];\n for (const c of root.children ?? []) {\n const p = pathTo(c, id);\n if (p.length) return [root, ...p];\n }\n return [];\n}\n\n/** The parent of a topic (null for the root or a stranger). */\nexport function parentOf(root, id) {\n const p = pathTo(root, id);\n return p.length > 1 ? p[p.length - 2] : null;\n}\n\n/** How many topics sit under this one, at every depth. */\nexport function countTopics(topic) {\n let n = 0;\n for (const c of topic?.children ?? []) n += 1 + countTopics(c);\n return n;\n}\n\n/** Is `id` the topic `ancestor` or anywhere under it? */\nexport function isWithin(root, ancestorId, id) {\n const a = findTopic(root, ancestorId);\n return Boolean(a && findTopic(a, id));\n}\n\n// ── changing ─────────────────────────────────────────────────────────────\n\n/** A copy of the tree with one topic replaced by fn(topic). */\nexport function mapTopic(root, id, fn) {\n if (!root) return root;\n if (root.id === id) return fn(root);\n let changed = false;\n const kids = (root.children ?? []).map((c) => {\n const next = mapTopic(c, id, fn);\n if (next !== c) changed = true;\n return next;\n });\n return changed ? { ...root, children: kids } : root;\n}\n\n/** Merge a patch into one topic (`{ text }`, `{ color }`, `{ collapsed }`…). */\nexport function updateTopic(root, id, patch) {\n return mapTopic(root, id, (t) => {\n const next = { ...t, ...(typeof patch === \"function\" ? patch(t) : patch) };\n for (const k of [\"color\", \"collapsed\", \"side\", \"note\"]) if (next[k] === undefined || next[k] === false || next[k] === \"\") delete next[k];\n return next;\n });\n}\n\n// Main branches keep the side they are on once the map changes around\n// them, so adding one never flips the others across the centre.\nfunction pinSides(root) {\n const kids = root.children ?? [];\n if (kids.every((c) => c.side)) return root;\n const sides = splitSides(root);\n return { ...root, children: kids.map((c) => (c.side ? c : { ...c, side: sides.get(c.id) })) };\n}\n\n/** Put `topic` under `parentId` at `index` (default: last). A new main\n * branch goes on the lighter side unless it names one. */\nexport function insertTopic(root, parentId, topic, index) {\n const t = topic ?? createTopic();\n if (parentId === root.id) {\n const pinned = pinSides(root);\n const kids = [...(pinned.children ?? [])];\n let side = t.side;\n if (!side) {\n const l = kids.filter((c) => c.side === \"left\").reduce((n, c) => n + 1 + visibleCount(c), 0);\n const r = kids.filter((c) => c.side !== \"left\").reduce((n, c) => n + 1 + visibleCount(c), 0);\n side = l < r ? \"left\" : \"right\";\n }\n const at = index === undefined ? kids.length : Math.max(0, Math.min(kids.length, index));\n kids.splice(at, 0, { ...t, side });\n return { ...pinned, children: kids };\n }\n return mapTopic(root, parentId, (p) => {\n const kids = [...(p.children ?? [])];\n const { side: _s, ...rest } = t;\n const at = index === undefined ? kids.length : Math.max(0, Math.min(kids.length, index));\n kids.splice(at, 0, rest);\n const next = { ...p, children: kids };\n delete next.collapsed;\n return next;\n });\n}\n\nfunction visibleCount(t) {\n if (t.collapsed) return 0;\n let n = 0;\n for (const c of t.children ?? []) n += 1 + visibleCount(c);\n return n;\n}\n\n/** A new topic next to `id` (after it, or before with `before`). On the\n * root there is no next to: it becomes a child. */\nexport function insertSibling(root, id, topic, { before = false } = {}) {\n const parent = parentOf(root, id);\n if (!parent) return insertTopic(root, root.id, topic);\n const i = parent.children.findIndex((c) => c.id === id);\n const me = parent.children[i];\n const t = parent.id === root.id && me?.side ? { ...topic, side: topic.side ?? me.side } : topic;\n return insertTopic(root, parent.id, t, before ? i : i + 1);\n}\n\n/** Take a topic (and its branch) out. The root stays. */\nexport function removeTopic(root, id) {\n if (!root || root.id === id) return root;\n const parent = parentOf(root, id);\n if (!parent) return root;\n return mapTopic(root, parent.id, (p) => ({ ...p, children: p.children.filter((c) => c.id !== id) }));\n}\n\n/** Move a topic (with its branch) under `parentId` at `index` (default\n * last). Refused (the same tree back) into its own branch. */\nexport function moveTopic(root, id, parentId, index) {\n if (id === root.id || isWithin(root, id, parentId)) return root;\n const t = findTopic(root, id);\n if (!t) return root;\n const from = parentOf(root, id);\n let at = index;\n if (from?.id === parentId && at !== undefined) {\n const was = from.children.findIndex((c) => c.id === id);\n if (was < at) at -= 1;\n }\n const without = removeTopic(root, id);\n const moved = { ...t };\n if (parentId !== root.id) delete moved.side;\n else if (from?.id !== root.id) delete moved.side;\n return insertTopic(without, parentId, moved, at);\n}\n\n/** Swap a topic with its neighbour: `delta` -1 (up) or +1 (down). A main\n * branch trades places with the next one on its own side. */\nexport function shiftTopic(root, id, delta) {\n const parent = parentOf(root, id);\n if (!parent) return root;\n const base = parent.id === root.id ? pinSides(root) : parent;\n const kids = base.children;\n const i = kids.findIndex((c) => c.id === id);\n const sameSide = (c) => parent.id !== root.id || (c.side ?? \"right\") === (kids[i].side ?? \"right\");\n let j = i + delta;\n while (j >= 0 && j < kids.length && !sameSide(kids[j])) j += delta;\n if (j < 0 || j >= kids.length) return root;\n const next = [...kids];\n [next[i], next[j]] = [next[j], next[i]];\n if (parent.id === root.id) return { ...base, children: next };\n return mapTopic(root, parent.id, (p) => ({ ...p, children: next }));\n}\n\n/** Tab on an outline: the topic becomes the last child of the topic\n * before it. Nothing before it: the same tree back. */\nexport function indentTopic(root, id) {\n const parent = parentOf(root, id);\n if (!parent) return root;\n const kids = parent.id === root.id ? pinSides(root).children : parent.children;\n const i = kids.findIndex((c) => c.id === id);\n const me = kids[i];\n let j = i - 1;\n if (parent.id === root.id) while (j >= 0 && (kids[j].side ?? \"right\") !== (me.side ?? \"right\")) j -= 1;\n if (j < 0) return root;\n return moveTopic(root, id, kids[j].id);\n}\n\n/** Shift+Tab: the topic moves up a level, to just after its parent. */\nexport function outdentTopic(root0, id) {\n const root = pinSides(root0);\n const path = pathTo(root, id);\n if (path.length < 3) return root0;\n const parent = path[path.length - 2];\n const grand = path[path.length - 3];\n const at = grand.children.findIndex((c) => c.id === parent.id) + 1;\n const t = findTopic(root, id);\n const moved = grand.id === root.id ? { ...t, side: parent.side } : t;\n const without = removeTopic(root, id);\n return insertTopic(without, grand.id, moved, at);\n}\n\n/** A copy of a branch with fresh ids, placed right after it. */\nexport function duplicateTopic(root, id) {\n const t = findTopic(root, id);\n if (!t || id === root.id) return { root, id: null };\n const copy = (x) => ({ ...x, id: topicId(), children: (x.children ?? []).map(copy) });\n const c = copy(t);\n return { root: insertSibling(root, id, c), id: c.id };\n}\n\n/** Fold (or unfold) every branch that has subtopics, the root aside. */\nexport function setAllCollapsed(root, collapsed) {\n const walk = (t, depth) => {\n const kids = (t.children ?? []).map((c) => walk(c, depth + 1));\n const next = { ...t, children: kids };\n if (depth > 0 && collapsed && kids.length) next.collapsed = true;\n else delete next.collapsed;\n return next;\n };\n return walk(root, 0);\n}\n\n// ── outline in, outline out ──────────────────────────────────────────────\n\n/**\n * An indented outline → topics (a forest: an array of top-level topics).\n * Bullets (-, *, •, 1.), markdown headings (# is the top), tabs or spaces;\n * blank lines skipped. What an agent writes, or a list pasted from notes.\n */\nexport function outlineToTopics(text) {\n const lines = String(text ?? \"\")\n .replace(/\\r\\n?/g, \"\\n\")\n .split(\"\\n\")\n .filter((l) => l.trim());\n if (!lines.length) return [];\n const rows = lines.map((l) => {\n const heading = /^\\s*(#{1,6})\\s+(.*)$/.exec(l);\n if (heading) return { level: heading[1].length - 7, text: heading[2].trim() };\n const lead = /^[\\t ]*/.exec(l)[0].replace(/\\t/g, \" \").length;\n const body = l.trim().replace(/^(?:[-*+•]|\\d+[.)])\\s+/, \"\").replace(/^\\[[ xX]\\]\\s+/, \"\");\n return { level: lead, text: body.trim() };\n });\n // Headings sit above every indented line; indents rank by their width.\n const levels = [...new Set(rows.map((r) => r.level))].sort((a, b) => a - b);\n const rank = new Map(levels.map((l, i) => [l, i]));\n const forest = [];\n const stack = [];\n for (const r of rows) {\n const depth = rank.get(r.level);\n const t = createTopic(r.text);\n while (stack.length && stack[stack.length - 1].depth >= depth) stack.pop();\n if (stack.length) stack[stack.length - 1].topic.children.push(t);\n else forest.push(t);\n stack.push({ depth, topic: t });\n }\n return forest;\n}\n\n/** An outline → a map. One top line: it is the centre. Several: they\n * hang off a centre called `rootText`. */\nexport function outlineToMindMap(text, { rootText = \"Central topic\" } = {}) {\n const forest = outlineToTopics(text);\n if (forest.length === 1) return { ...forest[0], id: \"root\" };\n return { id: \"root\", text: rootText, children: forest };\n}\n\n/** The map as an indented outline, two spaces a level — for an agent, a\n * clipboard, a search column. Folded branches are included. */\nexport function mindMapToText(topic) {\n const out = [];\n const walk = (t, depth) => {\n out.push(`${\" \".repeat(depth)}${depth ? \"- \" : \"\"}${String(t.text ?? \"\").replace(/\\n/g, \" \") || \"Untitled\"}`);\n for (const c of t.children ?? []) walk(c, depth + 1);\n };\n if (topic) walk(topic, 0);\n return out.join(\"\\n\");\n}\n\n/** The map as Markdown: the centre as a heading, the branches as a\n * nested list. */\nexport function mindMapToMarkdown(root) {\n if (!root) return \"\";\n const lines = [`# ${String(root.text ?? \"\").replace(/\\n/g, \" \") || \"Untitled\"}`, \"\"];\n const walk = (t, depth) => {\n lines.push(`${\" \".repeat(depth)}- ${String(t.text ?? \"\").replace(/\\n/g, \" \") || \"Untitled\"}`);\n for (const c of t.children ?? []) walk(c, depth + 1);\n };\n for (const c of root.children ?? []) walk(c, 0);\n return lines.join(\"\\n\");\n}\n\n// ── layout ───────────────────────────────────────────────────────────────\n\n// Type and box per level, in px. The node draws with exactly these, so\n// what is laid out is what is shown. level 0 = the centre, 1 = a main\n// branch (a tinted pill), 2+ = a subtopic (words over a coloured line).\nexport const LEVELS = [\n { font: 18, weight: 600, line: 26, padX: 20, padY: 12, min: 120, max: 280, gap: 56 },\n { font: 14, weight: 500, line: 20, padX: 14, padY: 8, min: 64, max: 220, gap: 36 },\n { font: 14, weight: 400, line: 20, padX: 6, padY: 5, min: 44, max: 240, gap: 28 },\n];\nexport const levelOf = (depth) => LEVELS[Math.min(depth, LEVELS.length - 1)];\n\n/** The smallest zoom a map opens at: its smallest words (14px, a main\n * branch's and a subtopic's) drawn no smaller than the kit's smallest\n * text, text-2xs (11px). 11 / 14 ≈ 0.79. */\nexport const READABLE_ZOOM = 11 / Math.min(...LEVELS.map((l) => l.font));\n\n/**\n * How a map opens in a view `width` × `height` px, as options for the\n * canvas's fitView: the whole map when it fits at READABLE_ZOOM or more\n * (→ {}), else READABLE_ZOOM centred on the centre topic `rootId`. Until\n * 0.5.1 a big map opened fitted whole whatever the zoom: 31% on a phone,\n * its words unreadable. `nodes` are the board's ({ position, width,\n * height }); `padding` is the fit's share of the view (the canvas's 0.2),\n * split as React Flow splits it.\n */\nexport function openingView(nodes, { width, height, rootId, padding = 0.2 } = {}) {\n if (!(width > 0 && height > 0) || !nodes?.length || rootId == null) return {};\n let x1 = Infinity;\n let y1 = Infinity;\n let x2 = -Infinity;\n let y2 = -Infinity;\n for (const n of nodes) {\n const x = n.position?.x ?? 0;\n const y = n.position?.y ?? 0;\n x1 = Math.min(x1, x);\n y1 = Math.min(y1, y);\n x2 = Math.max(x2, x + (n.width ?? 0));\n y2 = Math.max(y2, y + (n.height ?? 0));\n }\n const room = (v) => v - 2 * Math.floor((v - v / (1 + padding)) / 2);\n const fit = Math.min(room(width) / Math.max(1, x2 - x1), room(height) / Math.max(1, y2 - y1));\n return fit >= READABLE_ZOOM ? {} : { nodes: [{ id: rootId }], minZoom: READABLE_ZOOM, maxZoom: READABLE_ZOOM };\n}\nconst SIBLING_GAP = [0, 20, 8];\nconst siblingGap = (depth) => SIBLING_GAP[Math.min(depth, SIBLING_GAP.length - 1)];\n/** Room left under a subtopic for its coloured line. */\nexport const UNDERLINE = 2;\n\n/** A width guess without a DOM: average glyph widths for a UI sans. */\nexport function estimateText(text, { font = 14, weight = 400 } = {}) {\n let w = 0;\n for (const ch of String(text ?? \"\")) w += /[A-Z]/.test(ch) ? 0.66 : /[mwMW]/.test(ch) ? 0.8 : /[ilj.,:;'!|]/.test(ch) ? 0.28 : ch === \" \" ? 0.28 : 0.54;\n return w * font * (weight >= 600 ? 1.05 : weight >= 500 ? 1.02 : 1);\n}\n\n/** Words wrapped at `max` px → { width, lines }. */\nexport function wrapText(text, max, measure, style) {\n const paras = String(text ?? \"\").split(\"\\n\");\n let width = 0;\n let lines = 0;\n const space = measure(\" \", style);\n for (const para of paras) {\n const words = para.split(/\\s+/).filter(Boolean);\n if (!words.length) {\n lines += 1;\n continue;\n }\n let line = 0;\n for (const w of words) {\n const ww = measure(w, style);\n if (line && line + space + ww > max) {\n width = Math.max(width, line);\n lines += 1;\n line = 0;\n }\n if (ww > max) {\n // One word longer than the line: it breaks where it must.\n const extra = Math.floor(ww / max);\n lines += extra;\n width = max;\n line = ww - extra * max;\n continue;\n }\n line = line ? line + space + ww : ww;\n }\n width = Math.max(width, line);\n lines += 1;\n }\n return { width: Math.min(max, width), lines: Math.max(1, lines) };\n}\n\n/** The box a topic needs at a depth: { width, height }. `placeholder`\n * sizes an empty topic. */\nexport function topicSize(text, depth, measure = estimateText, placeholder = \"New topic\") {\n const L = levelOf(depth);\n const style = { font: L.font, weight: L.weight };\n const words = String(text ?? \"\").trim() ? text : placeholder;\n const { width, lines } = wrapText(words, L.max - L.padX * 2, measure, style);\n // +2: a browser wraps a hair sooner than the sum of the words' widths.\n const w = Math.max(L.min, Math.ceil(width + 2) + L.padX * 2);\n const h = lines * L.line + L.padY * 2 + (depth >= 2 ? UNDERLINE : 0);\n return { width: w, height: h };\n}\n\n/** Which side each main branch grows on: its own `side`, else split in\n * order so the two sides hold about as many rows. Counted in topics, not\n * pixels, so the answer never depends on the font. Map(id → side). */\nexport function splitSides(root) {\n const kids = root?.children ?? [];\n const rows = (t) => (t.collapsed || !t.children?.length ? 1 : t.children.reduce((n, c) => n + rows(c), 0));\n const w = kids.map(rows);\n const out = new Map();\n let L = 0;\n let R = 0;\n const free = [];\n kids.forEach((c, i) => {\n if (c.side === \"left\") (L += w[i]), out.set(c.id, \"left\");\n else if (c.side === \"right\") (R += w[i]), out.set(c.id, \"right\");\n else free.push(i);\n });\n // The first k free branches go right, the rest left: the k that leaves\n // the two sides closest wins (a tie: more on the right).\n let best = 0;\n let bestDiff = Infinity;\n const total = free.reduce((n, i) => n + w[i], 0);\n let acc = 0;\n for (let k = 0; k <= free.length; k++) {\n const diff = Math.abs(R + acc - (L + total - acc));\n if (diff <= bestDiff) {\n bestDiff = diff;\n best = k;\n }\n if (k < free.length) acc += w[free[k]];\n }\n free.forEach((i, n) => out.set(kids[i].id, n < best ? \"right\" : \"left\"));\n return out;\n}\n\n/**\n * Where every visible topic goes. The centre at (0, 0); main branches\n * on both sides, balanced; each branch a tidy tree growing outwards, a\n * topic centred on its subtopics. Folded branches stop at the fold.\n *\n * measure(text, { font, weight }) → px (default: estimateText)\n * colors the branch palette (default BRANCH_COLORS)\n *\n * Returns { items, bounds }. Each item: { id, topic, parentId, depth,\n * side (\"root\" | \"left\" | \"right\"), color, x, y, width, height (top-left\n * box), hidden (topics folded away under it), from / to (where the line\n * from the parent leaves and arrives, board px) }.\n */\nexport function layoutMindMap(root, { measure = estimateText, colors = BRANCH_COLORS } = {}) {\n if (!root) return { items: [], bounds: { x: 0, y: 0, width: 0, height: 0 } };\n const sizes = new Map();\n const sizeOf = (t, depth) => {\n const k = t.id;\n if (!sizes.has(k)) sizes.set(k, topicSize(t.text, depth, measure));\n return sizes.get(k);\n };\n const block = new Map();\n const blockOf = (t, depth) => {\n if (block.has(t.id)) return block.get(t.id);\n const own = sizeOf(t, depth).height;\n let h = own;\n if (!t.collapsed && t.children?.length) {\n const sum = t.children.reduce((n, c) => n + blockOf(c, depth + 1), 0) + siblingGap(depth + 1) * (t.children.length - 1);\n h = Math.max(own, sum);\n }\n block.set(t.id, h);\n return h;\n };\n\n const items = [];\n const rs = sizeOf(root, 0);\n const rootItem = { id: root.id, topic: root, parentId: null, depth: 0, side: \"root\", color: null, x: -rs.width / 2, y: -rs.height / 2, width: rs.width, height: rs.height, hidden: root.collapsed ? countTopics(root) : 0 };\n items.push(rootItem);\n\n // Where a line leaves a topic towards its children on `side`, and where\n // a line arrives at it.\n const outAnchor = (it, side) => {\n const dir = side === \"left\" ? -1 : 1;\n const x = dir > 0 ? it.x + it.width : it.x;\n const y = it.depth >= 2 ? it.y + it.height - UNDERLINE / 2 : it.y + it.height / 2;\n return { x, y, dir };\n };\n const inAnchor = (it) => {\n const x = it.side === \"left\" ? it.x + it.width : it.x;\n const y = it.depth >= 2 ? it.y + it.height - UNDERLINE / 2 : it.y + it.height / 2;\n return { x, y };\n };\n\n const place = (t, parentItem, depth, side, top, color) => {\n const s = sizeOf(t, depth);\n const b = blockOf(t, depth);\n const from = outAnchor(parentItem, side);\n const gap = levelOf(depth - 1).gap;\n const cy = top + b / 2;\n const x = side === \"left\" ? from.x - gap - s.width : from.x + gap;\n // A subtopic's line is its foot: centre the words so the line sits\n // at the middle of its children.\n const y = cy - s.height / 2;\n const it = { id: t.id, topic: t, parentId: parentItem.id, depth, side, color, x, y, width: s.width, height: s.height, hidden: t.collapsed ? countTopics(t) : 0 };\n it.from = { x: from.x, y: from.y };\n it.to = inAnchor(it);\n items.push(it);\n if (!t.collapsed && t.children?.length) {\n const sum = t.children.reduce((n, c) => n + blockOf(c, depth + 1), 0) + siblingGap(depth + 1) * (t.children.length - 1);\n let y2 = cy - sum / 2;\n for (const c of t.children) {\n place(c, it, depth + 1, side, y2, c.color ?? color);\n y2 += blockOf(c, depth + 1) + siblingGap(depth + 1);\n }\n }\n };\n\n if (!root.collapsed) {\n const sides = splitSides(root);\n const kids = root.children ?? [];\n for (const side of [\"right\", \"left\"]) {\n const mine = kids.map((c, i) => ({ c, i })).filter(({ c }) => sides.get(c.id) === side);\n if (!mine.length) continue;\n const sum = mine.reduce((n, { c }) => n + blockOf(c, 1), 0) + siblingGap(1) * (mine.length - 1);\n let y = -sum / 2;\n for (const { c, i } of mine) {\n place(c, rootItem, 1, side, y, c.color ?? colors[i % colors.length]);\n y += blockOf(c, 1) + siblingGap(1);\n }\n }\n }\n\n let x1 = Infinity;\n let y1 = Infinity;\n let x2 = -Infinity;\n let y2 = -Infinity;\n for (const it of items) {\n x1 = Math.min(x1, it.x);\n y1 = Math.min(y1, it.y);\n x2 = Math.max(x2, it.x + it.width);\n y2 = Math.max(y2, it.y + it.height);\n }\n return { items, bounds: { x: x1, y: y1, width: x2 - x1, height: y2 - y1 } };\n}\n\n/** The map as a FlowCanvas board: one `topic` node per visible topic,\n * laid out (layoutMindMap), no connectors (each topic draws the line to\n * its parent). What MindMap hands the canvas. */\nexport function mindMapToBoard(root, opts) {\n const { items } = layoutMindMap(root, opts);\n const round = (n) => Math.round(n * 100) / 100;\n return {\n nodes: items.map((it) => ({\n id: it.id,\n type: \"topic\",\n position: { x: round(it.x), y: round(it.y) },\n width: Math.round(it.width),\n height: Math.round(it.height),\n draggable: false,\n connectable: false,\n data: {\n text: it.topic.text,\n depth: it.depth,\n side: it.side,\n color: it.color,\n kids: it.topic.children?.length ?? 0,\n collapsed: Boolean(it.topic.collapsed),\n hidden: it.hidden,\n link: it.from ? { x1: round(it.from.x - it.x), y1: round(it.from.y - it.y), x2: round(it.to.x - it.x), y2: round(it.to.y - it.y) } : null,\n },\n })),\n edges: [],\n };\n}\n\n/** The curve from a parent to a child, in the child's own coordinates. */\nexport function branchPath({ x1, y1, x2, y2 }) {\n const dx = (x2 - x1) * 0.55;\n return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;\n}\n\n// ── starters ─────────────────────────────────────────────────────────────\n\nconst t = (text, children = []) => ({ text, children: children.map((c) => (typeof c === \"string\" ? t(c) : c)) });\n\n/** What an empty map offers. Each `map()` makes a fresh tree. */\nexport function mindMapTemplates() {\n return [\n {\n id: \"project\",\n label: \"Project plan\",\n icon: \"flag\",\n description: \"Goals, scope, people, risks\",\n map: () => normalizeMap(t(\"Project\", [t(\"Goals\", [\"Why now\", \"What success looks like\"]), t(\"Scope\", [\"In\", \"Out\"]), t(\"People\", [\"Owner\", \"Helpers\"]), t(\"Milestones\"), t(\"Risks\")])),\n },\n {\n id: \"brainstorm\",\n label: \"Brainstorm\",\n icon: \"lightbulb\",\n description: \"One question, ideas around it\",\n map: () => normalizeMap(t(\"How might we…?\", [\"Idea\", \"Idea\", \"Idea\", \"Idea\"])),\n },\n {\n id: \"meeting\",\n label: \"Meeting notes\",\n icon: \"message-square\",\n description: \"Agenda, decisions, actions\",\n map: () => normalizeMap(t(\"Meeting\", [t(\"Agenda\"), t(\"Notes\"), t(\"Decisions\"), t(\"Actions\")])),\n },\n ];\n}\n"
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"path": "registry/blocks/mind-map/README.md",
|
|
39
39
|
"type": "registry:file",
|
|
40
40
|
"target": "@components/makersclaw/blocks/mind-map/README.md",
|
|
41
|
-
"content": "# MindMap\n\nA mind map you can build without touching the mouse. One centre topic,\nmain branches balanced on both sides of it, each branch a tidy tree in its\nown colour. **Tab** adds a subtopic, **Enter** the next topic, typing\nrenames, the arrows walk the map. The layout is automatic: topics never\nneed dragging into place, and the map re-balances as it grows.\n\nA bar on top holds undo and redo, the save state, the selected topic's\nactions (add, colour, fold, delete) and a Map / Outline switch. The\nOutline view is the map as a nested list — the reading of it for a screen\nreader, and a quick way to jump to a topic.\n\nIt is built on `flow-canvas` (which draws it, pans and zooms): this block\nowns the tree, the layout and the keys.\n\n```jsx\nimport { MindMap } from \"../components/blocks/mind-map\"; // a Makersclaw app (\"./blocks/mind-map\" from components/)\nimport { MindMap } from \"@/components/makersclaw/blocks/mind-map\"; // a shadcn project\n```\n\n`MindMap` is a client component: render it from a `\"use client\"`\ncomponent. `makersclaw add mind-map` adds `flow-canvas` with it (and its\nnpm package, `@xyflow/react`: run `npm install` after). The tree helpers\n(`mindMapToText`, `outlineToMindMap`, `layoutMindMap`…) are plain\nfunctions and work on the server too.\n\n## The data\n\nA map is one tree of topics. Store it as it comes out of `onChange` (one\n`jsonb` column), and keep `mindMapToText(map)` beside it for search and\nagents.\n\n```js\n{\n id: \"root\", text: \"Relay 2.0 launch\",\n children: [\n { id: \"why\", text: \"Why now\", side: \"right\", children: [\n { id: \"why-1\", text: \"Churn up 3 pts\" },\n ] },\n { id: \"risks\", text: \"Risks\", side: \"left\", color: \"chart-4\", collapsed: true, children: [ … ] },\n ],\n}\n```\n\n- `id` — any string, unique in the map. `text` — the words (a newline is\n allowed). `children` — the subtopics, in order.\n- `collapsed: true` — the branch is folded; the map shows how many topics\n it hides.\n- `color` — a colour **token**, never a colour value: `\"chart-2\"` …\n `\"chart-5\"`, `\"chart-1\"` (the theme's own), `\"warning\"`, `\"success\"`,\n `\"info\"`, `\"destructive\"`. Subtopics inherit their branch's. A main\n branch with none takes the next of `colors`.\n- `side` — `\"left\"` or `\"right\"`, main branches only. Missing, the map\n splits the branches to balance the two sides; the first change pins\n them, so adding a branch never flips the others across the centre.\n- Anything else on a topic (`note`, your own keys) is kept as it is.\n\n`normalizeMap(anything)` cleans what it is given: missing or repeated ids,\n`title`/`label` for `text`, `topics`/`items` for `children`, an outline\nstring, or nothing (a fresh map). The component runs it on `value`.\n\n## Props\n\n- `value` — the map, controlled. A new `value` that is not what the map\n last sent through `onChange` replaces it (loading another record); that\n replacement can be undone. Pass
|
|
41
|
+
"content": "# MindMap\n\nA mind map you can build without touching the mouse. One centre topic,\nmain branches balanced on both sides of it, each branch a tidy tree in its\nown colour. **Tab** adds a subtopic, **Enter** the next topic, typing\nrenames, the arrows walk the map. The layout is automatic: topics never\nneed dragging into place, and the map re-balances as it grows.\n\nA bar on top holds undo and redo, the save state, the selected topic's\nactions (add, colour, fold, delete) and a Map / Outline switch. The\nOutline view is the map as a nested list — the reading of it for a screen\nreader, and a quick way to jump to a topic.\n\nIt is built on `flow-canvas` (which draws it, pans and zooms): this block\nowns the tree, the layout and the keys.\n\nA map opens fitted to its view, but never smaller than its words can be\nread: 79%, where its smallest words (14px) draw at the kit's smallest\ntext size (11px). A map too big for that — most maps on a phone — opens\nat 79% centred on its centre topic; ⇧1 still fits the whole of it.\n\n```jsx\nimport { MindMap } from \"../components/blocks/mind-map\"; // a Makersclaw app (\"./blocks/mind-map\" from components/)\nimport { MindMap } from \"@/components/makersclaw/blocks/mind-map\"; // a shadcn project\n```\n\n`MindMap` is a client component: render it from a `\"use client\"`\ncomponent. `makersclaw add mind-map` adds `flow-canvas` with it (and its\nnpm package, `@xyflow/react`: run `npm install` after). The tree helpers\n(`mindMapToText`, `outlineToMindMap`, `layoutMindMap`…) are plain\nfunctions and work on the server too.\n\n## The data\n\nA map is one tree of topics. Store it as it comes out of `onChange` (one\n`jsonb` column), and keep `mindMapToText(map)` beside it for search and\nagents.\n\n```js\n{\n id: \"root\", text: \"Relay 2.0 launch\",\n children: [\n { id: \"why\", text: \"Why now\", side: \"right\", children: [\n { id: \"why-1\", text: \"Churn up 3 pts\" },\n ] },\n { id: \"risks\", text: \"Risks\", side: \"left\", color: \"chart-4\", collapsed: true, children: [ … ] },\n ],\n}\n```\n\n- `id` — any string, unique in the map. `text` — the words (a newline is\n allowed). `children` — the subtopics, in order.\n- `collapsed: true` — the branch is folded; the map shows how many topics\n it hides.\n- `color` — a colour **token**, never a colour value: `\"chart-2\"` …\n `\"chart-5\"`, `\"chart-1\"` (the theme's own), `\"warning\"`, `\"success\"`,\n `\"info\"`, `\"destructive\"`. Subtopics inherit their branch's. A main\n branch with none takes the next of `colors`.\n- `side` — `\"left\"` or `\"right\"`, main branches only. Missing, the map\n splits the branches to balance the two sides; the first change pins\n them, so adding a branch never flips the others across the centre.\n- Anything else on a topic (`note`, your own keys) is kept as it is.\n\n`normalizeMap(anything)` cleans what it is given: missing or repeated ids,\n`title`/`label` for `text`, `topics`/`items` for `children`, an outline\nstring, or nothing (a fresh map). The component runs it on `value`.\n\n## Props\n\n- `value` — the map, controlled. A new `value` that is not what the map\n last sent through `onChange` replaces it (loading another record); that\n replacement can be undone, and when it is another map (a new centre, or\n other main branches) the view fits it, as it fits a first one. Pass\n back exactly what `onChange` gave you.\n- `defaultValue` — the starting map, uncontrolled. With neither, a centre\n topic called \"Central topic\" and the first-run card.\n- `onChange(map)` — the whole tree after a change settles (debounced by\n `changeDelay`; typing is saved as it goes). Selection, folding in\n read-only mode, panning and zooming never call it.\n- `onSave(map)` — `async`, returns `{ ok: true }` or `{ ok: false,\n error? }` (a throw counts as failed). Called right after `onChange`. The\n bar shows \"Saving…\", \"Saved\", or the error with Retry. A failure keeps\n the map as it is on screen (nothing typed is thrown away); Retry sends\n it again.\n- `changeDelay` — ms of quiet before `onChange` / `onSave` (default 400).\n- `readOnly` — `false` (default). `true`: no editing; the arrows still\n walk the map, Space folds and unfolds (not saved), the Outline view and\n copying work.\n- `colors` — the branch palette, token names (default `BRANCH_COLORS`:\n chart-2, 3, 4, 5, 1). The colour picker offers these plus warning,\n success, info and destructive.\n- `templates` — what the first-run card offers: `[{ id, label, icon?,\n description?, map }]`, `map` a tree or a function returning one (default\n `mindMapTemplates()`: Project plan, Brainstorm, Meeting notes). `[]` for\n none.\n- `placeholder` — what an empty topic shows while it is typed (default\n \"New topic\").\n- `height` — px or any CSS length, default `600`. A height class in\n `className` (`h-dvh`, `h-full`, `flex-1`) replaces it.\n- `label` — the map's accessible name (default \"Mind map\").\n- `onSelectionChange(topic | null)` — the selected topic changed (a side\n panel with its notes).\n- `className` — layout around it (margin, width, height). Never colour.\n\n## Keyboard\n\nWith a topic selected (click one, or press an arrow):\n\n| Key | Does |\n| --- | --- |\n| Tab | add a subtopic and type it |\n| Enter / Shift+Enter | add the next topic below / above |\n| Space, F2, or just type | rename |\n| Delete / Backspace | delete the topic and its branch (a toast offers Undo) |\n| ← → ↑ ↓ | walk the map: outwards to a child, inwards to the parent, up and down the column |\n| ⌥↑ / ⌥↓ | move the topic up or down among its siblings |\n| Shift+Tab | move the topic up a level |\n| ⌘↑ / ⌘↓ | fold / unfold the branch |\n| ⌘D | duplicate the branch |\n| ⌘C / ⌘X / ⌘V | copy or cut a branch as an outline; paste an outline (from notes, a doc, an agent) as subtopics |\n| ⌘Z / ⇧⌘Z | undo / redo |\n| Home | the centre |\n| Esc | clear the selection |\n| ⇧1 | fit the whole map to the screen, however small |\n| ? | the shortcuts sheet |\n\nWhile typing a topic: **Enter** finishes it and starts the next one (on\nan empty one it steps out a level, so a run of topics never needs the\nmouse), **Tab** finishes it and starts a subtopic (on an empty one it\nmoves in a level), **Backspace** on an empty topic removes it and goes\nback to the one before, **Esc** finishes. Shift+Enter is a new line.\n\nWith the mouse: click selects, double-click renames, the **+** at a\ntopic's end adds a subtopic, the chip at a branch's end folds it (folded,\nit shows the count it hides), and dragging a topic onto another moves its\nbranch there. A plain drag on the empty canvas pans; pinch or ⌘-scroll\nzooms. On a touch screen a drag always pans; tap selects, double-tap\nrenames, and the bar's buttons add.\n\n## The helpers\n\nPlain functions over a tree; each returns a new tree and leaves the old\none as it was.\n\n- Read: `findTopic(map, id)`, `pathTo(map, id)` → `[root, …, topic]`,\n `parentOf(map, id)`, `countTopics(topic)` (everything under it).\n- Change: `updateTopic(map, id, patch)`, `insertTopic(map, parentId,\n topic, index?)`, `insertSibling(map, id, topic, { before })`,\n `removeTopic(map, id)`, `moveTopic(map, id, parentId, index?)` (refused\n into its own branch), `shiftTopic(map, id, ±1)`, `indentTopic`,\n `outdentTopic`, `duplicateTopic(map, id)` → `{ root, id }`,\n `setAllCollapsed(map, on)`, `createTopic(text)`.\n- Text: `mindMapToText(map)` (an indented outline, for an agent or search),\n `mindMapToMarkdown(map)` (a heading and a nested list),\n `outlineToMindMap(text)` and `outlineToTopics(text)` (bullets, numbers,\n headings, tabs or spaces — what an agent writes).\n- Layout: `layoutMindMap(map, { measure?, colors? })` → `{ items, bounds }`\n (every visible topic's box, side, depth, colour and the line to its\n parent), `mindMapToBoard(map)` (the same as a flow-canvas board).\n- `mindMapTemplates()`, `BRANCH_COLORS`.\n\n## Example\n\n```jsx\n// components/PlanMap.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { MindMap, mindMapToText } from \"./blocks/mind-map\";\n\nexport default function PlanMap({ plan }) {\n const [map, setMap] = useState(plan.map); // jsonb, may be null\n return (\n <MindMap\n value={map ?? undefined}\n defaultValue={{ text: plan.name }}\n onChange={setMap}\n onSave={(m) => updateRow({ source: \"public.plans\", id: plan.id, patch: { map: m, map_text: mindMapToText(m) } })}\n label={`${plan.name} map`}\n className=\"h-[calc(100dvh-8rem)]\"\n />\n );\n}\n```\n\nAn agent's outline, straight onto a map:\n\n```jsx\nconst map = outlineToMindMap(`# Hiring plan\n- Roles\n - Founding engineer\n - Designer\n- Sourcing\n - Referrals\n - Communities`);\n<MindMap defaultValue={map} onSave={save} />\n```\n\n## What it will not do\n\n- Free placement: topics sit where the layout puts them (drag moves a\n branch to another topic, not to a spot). For free boards use\n `flow-canvas` or `whiteboard`.\n- Cross-links between branches, images or icons on topics.\n- Collaborate live: saves are last-write-wins.\n- Export an image.\n- Render the map on the server: the server shows it as text until the\n canvas mounts. For a read-only page that must be rich in the HTML,\n render `mindMapToMarkdown` yourself.\n\nA map 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 mind-map` restores them.\n"
|
|
42
42
|
}
|
|
43
43
|
],
|
|
44
44
|
"categories": [
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"docs": "Installed to components/makersclaw/blocks/mind-map. Import: import { MindMap, BRANCH_COLORS, normalizeMap, createTopic, findTopic, updateTopic, insertTopic, removeTopic, moveTopic, layoutMindMap, mindMapToBoard, mindMapToText, mindMapToMarkdown, outlineToMindMap, outlineToTopics, mindMapTemplates } from \"@/components/makersclaw/blocks/mind-map\". Props and an example: components/makersclaw/blocks/mind-map/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
61
61
|
"meta": {
|
|
62
62
|
"makersclaw": {
|
|
63
|
-
"version": "0.
|
|
63
|
+
"version": "0.5.2",
|
|
64
64
|
"useWhen": "A screen for thinking in a tree: brainstorming around one idea, planning a project, breaking a goal down, notes from a meeting, a topic outline an agent drafted. Not for free-form boards with stickies and arrows (flow-canvas, whiteboard), a people hierarchy (org-chart), or a process with steps (process-map)."
|
|
65
65
|
}
|
|
66
66
|
}
|
package/r/navigation-menu.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "Navigation Menu",
|
|
6
6
|
"description": "A site's top nav: plain links, and triggers that open a mega-menu panel of links (icon, title, one-line description) with an optional featured card; hover with intent, click, arrow keys and Escape; a menu button and a sheet of collapsible groups on a phone.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"docs": "Installed to components/makersclaw/blocks/navigation-menu. Import: import { NavigationMenu } from \"@/components/makersclaw/blocks/navigation-menu\". Props and an example: components/makersclaw/blocks/navigation-menu/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
46
46
|
"meta": {
|
|
47
47
|
"makersclaw": {
|
|
48
|
-
"version": "0.
|
|
48
|
+
"version": "0.5.2",
|
|
49
49
|
"useWhen": "The header of a public-facing site or a product with many destinations: a marketing site, a docs hub, a customer portal. Not for an app's own sidebar or a row of tabs that switch views on one page."
|
|
50
50
|
}
|
|
51
51
|
}
|
package/r/order-detail.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "OrderDetail",
|
|
6
6
|
"description": "One placed order on a page: its number, status and date, the items, the delivery address and contact, a timeline from placed to delivered (Steps), and the totals, with Track order and Invoice actions. Loading, error and not-found states built in.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"docs": "Installed to components/makersclaw/blocks/order-detail. Import: import { OrderDetail } from \"@/components/makersclaw/blocks/order-detail\". Props and an example: components/makersclaw/blocks/order-detail/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
48
48
|
"meta": {
|
|
49
49
|
"makersclaw": {
|
|
50
|
-
"version": "0.
|
|
50
|
+
"version": "0.5.2",
|
|
51
51
|
"useWhen": "A buyer or an operator opens one order: \"where is my order\", an order confirmation page, a support view of a purchase. Not for a list of orders (data-table) or for a basket before paying (cart)."
|
|
52
52
|
}
|
|
53
53
|
}
|