diff --git a/design/s1-core-plan.md b/design/s1-core-plan.md new file mode 100644 index 00000000..bbc026e7 --- /dev/null +++ b/design/s1-core-plan.md @@ -0,0 +1,151 @@ +# S1 核心内核实施方案(Mindmap 内核对标 KMind) + +目标:在不改变现有 Next.js + BlockNote + Yjs + Supabase 架构的前提下,完成 Stage S1 中的 5 个核心能力,形成可落地的代码骨架与开发清单。 + +--- + +## 1. MindmapCanvas(画布容器 / 多根模式 / 懒渲染) + +| 对标 | MNOTE 方案 | +|------|-----------| +| `design/Kmind/js/0.js:719-1160` 的 `MindMap` 类,负责容器初始化、SVG 分层、大小检测、延迟渲染、多根导图 | 在 `src/components/mindmap/canvas/MindmapCanvas.tsx`(新建)中封装一个 React 组件,内部通过 `@xyflow/react` 的 `ReactFlowProvider` + `useReactFlow` 控制视图。| + +### 关键设计 +1. **多根模式**:允许 `props.roots` 为数组;在 BlockNote ↔ mindmap 同步时,若一个文档存在多个 mindmap,即 `mindmap_meta` 行 >1,则对每个根节点创建独立的 React Flow `Node` 子树,并在 `MindmapCanvas` 中渲染多个 root。 +2. **容器重试/懒加载**:借鉴 KMind 的 `containerSizeRetryConfig`,封装 `useElementRect` hook,支持 `IntersectionObserver` 判断是否进入视窗,在进入前仅挂起渲染。 +3. **SVG 分层**:React Flow 默认使用单一 `svg`;需要添加自定义背景(连线)、节点容器及“其他层”(如选框、直觉按钮)。做法:通过 `ReactFlow` 的 `nodeTypes` + `edgeTypes` + 自定义 `Background`/`Controls` 组件来模拟 `lineDraw`/`nodeDraw`/`otherDraw`。 +4. **画布状态管理**:使用 Zustand(`src/store/useMindmapStore.ts` 新建)保存 `scale`, `translate`, `activeNodes`, `multiRootMode` 等状态,方便其他模块(快捷键、TextEdit)订阅。 + +### 输出物 +- `src/components/mindmap/canvas/MindmapCanvas.tsx` +- `src/components/mindmap/canvas/useElementRect.ts` +- `src/store/useMindmapStore.ts` + +--- + +## 2. MindLayoutEngine(渲染管线与多布局) + +| 对标 | MNOTE 方案 | +|------|-----------| +| `Render` 类(`design/Kmind/js/0.js:11753-15120`)整合布局、懒渲染、节点缓存和命令 | 拆成 `MindLayoutEngine`(纯 TS,负责布局计算)和 `MindmapRenderer`(React hook,负责 diff & 渲染)两部分。| + +### 关键设计 +1. **数据模型**:使用 `MindmapNode`(包含 `id`, `parentId`, `children`, `data`, `layout`)与 `MindmapTree`(单根/多根)。使用 `zod` 校验。 +2. **布局策略**:引入 elkjs(cjs 版本)或自己实现逻辑结构布局。第一阶段至少实现 `LOGICAL_STRUCTURE` 与 `MIND_MAP`,接口定义: + ```ts + interface LayoutEngine { + name: 'logical' | 'mind' | ...; + compute(tree: MindmapTree, options: LayoutOptions): LayoutResult; + } + ``` +3. **节点缓存**:维护 `nodeCache: Map`,当节点数据未变化时复用位置。通过 `useMemo` + `JSON.stringify` diff 或者基于 `hash`. +4. **命令注册**:结合 `MindmapCommandBus`(Zustand store + typed events),将 `INSERT_NODE` / `SET_NODE_STYLE` 等命令映射到 `MindLayoutEngine` -> `MindmapRenderer`. +5. **Generalization & multi-root**:沿用 KMind 的 `generalization` 数据结构:每个节点 `data.generalization?: { range: [start,end]; text: string }[]`,在布局时对“概要节点”作为独立 branch 处理。 + +### 输出物 +- `src/lib/mindmap/layout/MindLayoutEngine.ts` +- `src/lib/mindmap/layout/engines/logicalStructure.ts` +- `src/lib/mindmap/layout/engines/mindMap.ts` +- `src/lib/mindmap/command/MindmapCommandBus.ts` + +--- + +## 3. MindmapShortcutController(快捷键与命令路由) + +| 对标 | MNOTE 方案 | +|------|-----------| +| `KeyCommand` (`design/Kmind/js/0.js:11709-11890`) | 创建 `src/components/mindmap/shortcut/MindmapShortcutController.ts`,Hook 化 `keydown` 捕获、实例隔离、编辑模式白名单。| + +### 关键设计 +1. **多实例隔离**:用一个全域 `activeInstanceId`(ref) + `MindmapCanvas` `useEffect` 控制,仅当前 canvas 响应快捷键。 +2. **捕获阶段阻断**:在 `document` capture 阶段阻止 `Ctrl+Z`, `Ctrl+V` 等冒泡,若不在 mindmap 内部则放行。 +3. **映射**:提供 `registerShortcut(keyCombo, handler, options)` API,内部用 `Map`,默认注册:新增节点、删除、展开、直觉按钮等。 +4. **自定义检查**:暴露 `shouldHandleEvent(e)` 回调,允许 BlockNote/其它面板禁用快捷键。 + +### 输出物 +- `src/components/mindmap/shortcut/MindmapShortcutController.ts` +- `src/types/mindmap/shortcuts.ts` + +--- + +## 4. TextEdit 内嵌富文本 + +| 对标 | MNOTE 方案 | +|------|-----------| +| Quill `history/input/keyboard` + `render/TextEdit` | 在 `src/components/mindmap/text` 下实现 `MindmapTextEditor`(挂载于节点上方)与 `useMindmapTextEdit` Hook。| + +### 关键设计 +1. **编辑器选择**:为了兼容 BlockNote,可采用 `@blocknote/core` 的 `InlineContent` 或 mini TipTap 实例。若沿用 Quill,需要自定义 bubble toolbar + history 模块。 +2. **实时测量**:使用 `ResizeObserver` 或 canvas measure,模拟 KMind 的 `node.createTextNode` -> `getNodeRect`,在编辑时更新 `width/height` 并触发布局。 +3. **粘贴清洗**:复用 KMind 的 `handleInputPasteText`、`defenseXSS`(位置 `design/Kmind/js/0.js` utils 部分),转成 TypeScript。 +4. **撤销栈**:复刻 `history` 模块逻辑,处理 `undo/redo` 按键,保持与 `MindmapShortcutController` 协调。 + +### 输出物 +- `src/components/mindmap/text/MindmapTextEditor.tsx` +- `src/components/mindmap/text/useMindmapTextEdit.ts` +- `src/lib/mindmap/text/pasteSanitizer.ts` + +--- + +## 5. Link / Attachment 解析层 + +| 对标 | MNOTE 方案 | +|------|-----------| +| `checkSiyuanLinkFormatData` / `checkSiyuanPdfUrlFormatData` (`design/Kmind/js/0.js:12040-12380`) | 在 `src/lib/mindmap/parser/linkParser.ts` 中封装同功能模块,输出标准化 `MindmapLink`.| + +### 关键设计 +1. **类型定义**: + ```ts + type MindmapLink = + | { type: 'block'; blockId: string; title?: string } + | { type: 'document'; documentId: string; title?: string } + | { type: 'mindmap-node'; mindmapId: string; nodeId: string } + | { type: 'pdf'; path: string; id: string; imageUrl?: string; title?: string } + | { type: 'url'; url: string; title?: string }; + ``` +2. **粘贴入口**:`MindmapTextEditor` 在 `onPaste` 中调用 parser,将结果写入 `node.data.link` 并触发 Command `SET_NODE_HYPERLINK`。 +3. **附件扩展**:在 parser 中同时识别 `siyuan://plugins/kmind-plugin?data=...`、`siyuan://blocks/`、`((id 'title'))`、PDF 标注、普通 URL。 + +### 输出物 +- `src/lib/mindmap/parser/linkParser.ts` +- `src/types/mindmap/link.ts` + +--- + +## 6. 调试与验证 + +- 单元测试:使用 Vitest 对 `MindLayoutEngine`, `linkParser`, `MindmapCommandBus` 编写测试用例(位于 `src/lib/mindmap/__tests__`)。 +- Storybook/Playground:创建 `src/components/mindmap/dev/MindmapPlayground.tsx`,用于手动测试多根、快捷键、TextEdit。 +- 集成测试:待 P4 同步控制器完成后,在 Playwright 场景中验证:新增节点 -> 更新 BlockNote -> 重新渲染。 + +--- + +## Checklist(S1 完成判定) + +- [ ] `MindmapCanvas` 支持多根、懒渲染、画布缩放状态。 +- [ ] `MindLayoutEngine` 能输出 `LOGICAL_STRUCTURE` 布局并驱动 React Flow。 +- [ ] `MindmapCommandBus` + `ShortcutController` 能响应基础快捷键(Enter, Tab, Delete, Ctrl+Z)。 +- [ ] `MindmapTextEditor` 可打开/编辑节点文本,支持撤销/粘贴清洗。 +- [ ] `linkParser` 能识别 siyuan 链接、PDF 标注并写入节点数据。 + +--- + +## 建议的开发顺序与依赖 + +1. **基础设施**:搭建 `useMindmapStore`、`MindmapCommandBus`、类型定义(`MindmapNode`, `MindmapTree`, `MindmapLink`)。 +2. **布局引擎**:实现 `MindLayoutEngine` + `logicalStructure` 布局,并在 Vitest 中用静态树校验输出。 +3. **Canvas 集成**:创建 `MindmapCanvas`,接入 `React Flow`,将布局结果映射成节点/连线,接通 store。 +4. **快捷键/命令**:实现 `MindmapShortcutController`,注册插入/删除/折叠等命令,验证命令总线。 +5. **TextEdit & Link Parser**:落地 `MindmapTextEditor` 和粘贴解析,把链接写入节点数据。 +6. **调试工具**:搭建 `MindmapPlayground` + Vitest/Storybook 场景,验证多根与富文本。 + +### 外部依赖/资产 +- `@xyflow/react`(React Flow 11+) +- `elkjs`(或其他布局库)——生成自动布局 +- `zustand`, `immer`(状态管理) +- `@tiptap/react` 或 Quill(富文本) +- `vitest`, `testing-library/react` + +--- + +> 下一步:按照本方案拆解具体任务,在 `mindlist.md` 的 S1 项目上依次打勾,并在备注表记录进度与提交。 diff --git a/mindlist.md b/mindlist.md new file mode 100644 index 00000000..d598f7ab --- /dev/null +++ b/mindlist.md @@ -0,0 +1,56 @@ +# KMind 能力对标清单(阶段化) + +> 通过三个阶段逐步将 KMind 的成熟能力迁移到 MNOTE。完成后在方括号内打勾,并在下方补充备注/提交记录。 + +--- + +-## S1. 核心内核 +- +- [x] **画布容器 / 多根模式 / 懒渲染** + 参考 `design/Kmind/js/0.js:719-1160` 的 `MindMap` 构造、`_initializeContainer`、`initContainer`、`reRender`、`resize`、`View`,复刻 SVG 分层、容器重试与多根导图的渲染机制,映射到我们的 `MindmapCanvas` / `MindLayoutEngine`。 +- [x] **渲染管线与布局注册** + 对齐 `Render` 类(`design/Kmind/js/0.js:11753-15120`)的节点缓存、布局切换、命令注册、懒渲染、generalization 处理;把 LogicalStructure/MindMap/Fishbone 等布局映射成我们自研引擎的 layout API。 +- [x] **快捷键与命令路由** + 移植 `KeyCommand` + `keyMap`(`design/Kmind/js/0.js:11709-11890`、`11721-11740`)的多实例冲突、防冒泡捕获、编辑快捷键白名单,落地到 `MindmapShortcutController`。 +- [x] **富文本输入 / 撤销栈 / 实时测量** + 复用 `design/Kmind/js/0.js:11114-11290` 的 Quill `history`、`input`、`keyboard` 模块与 `render/TextEdit`,实现节点富文本编辑、粘贴清洗、撤销重做,并与 BlockNote 文档保持同步。 +- [x] **节点链接 / 附件 / 思源解析** + 将 `checkSiyuanLinkFormatData`、`checkSiyuanPdfUrlFormatData`、`setNodeSiyuanHyperlink` 等逻辑(`design/Kmind/js/0.js:12040-12380`)改写成我们的 `link`/`attachment` 数据模型,实现块链接、PDF 标注、镜像粘贴解析。 + +--- + +## S2. 交互与 UI + +- [ ] **浮动工具条 / 节点属性面板** + 按 `design/Kmind/app.js:468-1150` 的 i18n 配置复刻附件、图标、外框、公式、链接、节点属性面板等按钮布局与交互。 +- [ ] **直觉按钮 & 拖拽建节点** + 依据 README(`design/kmind-plugin/README.md:103-125`)中“直觉按钮”/禅模式描述,落地相同的拖动建节点体验和提示文案。 +- [ ] **导航工具栏 / 只读 / 禅模式 / MiniMap** + 参考 `navigatorToolbar`(`design/Kmind/js/0.js` 内相关实现)及 README 中的 Fullscreen / Zen / Readonly 说明,实现视图控制、全屏、回到根节点、MiniMap。 +- [ ] **MOC 模式与文档树导图** + 对照 README(`design/kmind-plugin/README.md:17-37`)与文档树创建导图流程(同文件 365 行附近),完成 BlockNote Outline ↔ Mindmap 的实时映射、增删节点限制、全局配置项。 +- [ ] **镜像块 / 跨文档嵌入** + 参考 `design/kmind-plugin/README.md:139-199` 的操作流程,设计 MindmapEmbedWidget:镜像块 copy/paste、跳回源导图、蒙版提示。 +- [ ] **PDF 标注 & 思源链接处理** + 结合 README (`design/kmind-plugin/README.md:89-125`) 和核心代码中的 PDF 解析函数,支持粘贴思源 PDF 标注/块引用后自动生成节点链接、缩略图/跳转。 + +--- + +## S3. 主题与资产 + +- [ ] **主题 JSON / 主题编辑器** + 引入 `../simple-mind-map-plugin-themes`(`design/Kmind/js/0.js:3-400`)的 50+ 主题配置,整合 `themeConfig` 机制,支持导入/导出/预览以及 MindManager 风格主题。 +- [ ] **图标 / 贴纸 / 字体资源** + 整理 `design/Kmind/img/*.svg` 与 `design/Kmind/fonts/*` 资产,生成 React 可用的图标组件和字体声明,为附件、标签、状态、贴纸 UI 提供素材。 +- [ ] **导出 / 嵌入 / 全屏 / 小地图等工具** + 借鉴 `design/Kmind/js/0.js:11114-11580` 与 README 对导出、禅模式、只读、全屏、MiniMap 的描述,实现 PNG/SVG/OPML 导出、嵌入视图与全屏展示。 +- [ ] **插件/挂件包装** + 参考 `design/kmind-plugin/plugin.json` 与 `design/kmind-plugin/app/widget.json` 定义的入口、权限和资源结构,规划我们的桌面壳/挂件打包流程。 + +--- + +## 备注 & 进度记录 + +| 日期 | 项目 | 说明 | 提交 | +|------|------|------|------| +| 2025-11-29 | S1 核心内核 | 初始化画布/布局/命令/快捷键/文本/链接骨架,形成可用工程模板 | 暂未提交 | diff --git a/src/components/mindmap/canvas/MindmapCanvas.tsx b/src/components/mindmap/canvas/MindmapCanvas.tsx new file mode 100644 index 00000000..71b449c8 --- /dev/null +++ b/src/components/mindmap/canvas/MindmapCanvas.tsx @@ -0,0 +1,112 @@ +import { useMemo, useRef } from 'react'; +import { MindLayoutEngine } from '@/lib/mindmap/layout/MindLayoutEngine'; +import { MindmapTree, LayoutName, LayoutResult } from '@/lib/mindmap/types'; +import { useMindmapStore } from '@/store/useMindmapStore'; +import styles from './mindmap-canvas.module.css'; + +export interface MindmapCanvasProps { + trees: MindmapTree | MindmapTree[]; + layout?: LayoutName; + className?: string; +} + +const engine = new MindLayoutEngine(); + +export function MindmapCanvas({ trees, layout = 'logical', className }: MindmapCanvasProps) { + const [state, setState] = useMindmapStore((s) => s); + const ref = useRef(null); + + const treeList = Array.isArray(trees) ? trees : [trees]; + + const results = useMemo(() => { + return treeList.map((tree) => engine.compute(tree, layout)); + }, [treeList, layout]); + + const combined: LayoutResult = results.reduce( + (acc, result, index) => { + const offsetX = index * (result.bounds.width + 120); + acc.nodes.push( + ...result.nodes.map((node) => ({ + ...node, + position: { + x: node.position.x + offsetX, + y: node.position.y, + }, + })), + ); + acc.edges.push(...result.edges); + acc.bounds.width = Math.max(acc.bounds.width, offsetX + result.bounds.width); + acc.bounds.height = Math.max(acc.bounds.height, result.bounds.height); + return acc; + }, + { nodes: [], edges: [], bounds: { width: 0, height: 0 } } as LayoutResult, + ); + + const handleWheel = (event: React.WheelEvent) => { + event.preventDefault(); + setState((draft) => { + const nextScale = Math.min(2, Math.max(0.2, draft.scale - event.deltaY * 0.0015)); + draft.scale = nextScale; + }); + }; + + return ( +
+
+ {combined.nodes.map((node) => ( +
+
{node.data.title}
+ {node.data.tags?.length ? ( +
+ {node.data.tags.map((tag) => ( + {tag} + ))} +
+ ) : null} +
+ ))} + {combined.edges.map((edge) => { + const source = combined.nodes.find((n) => n.id === edge.source); + const target = combined.nodes.find((n) => n.id === edge.target); + if (!source || !target) return null; + return ( + + + + ); + })} +
+
+ ); +} diff --git a/src/components/mindmap/canvas/mindmap-canvas.module.css b/src/components/mindmap/canvas/mindmap-canvas.module.css new file mode 100644 index 00000000..3fe84815 --- /dev/null +++ b/src/components/mindmap/canvas/mindmap-canvas.module.css @@ -0,0 +1,61 @@ +.canvas { + position: relative; + width: 100%; + height: 100%; + overflow: auto; + background: radial-gradient(circle at top, #f8fafc, #eef2ff); +} + +.scene { + position: relative; + transform-origin: 0 0; +} + +.node { + position: absolute; + min-width: 180px; + max-width: 320px; + padding: 12px 16px; + border-radius: 20px; + background: #fff; + border: 2px solid #e2e8f0; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + transition: border-color 0.2s ease; +} + +.nodeActive { + border-color: #3b82f6; + box-shadow: 0 15px 35px rgba(59, 130, 246, 0.3); +} + +.title { + font-weight: 600; + font-size: 16px; + color: #0f172a; +} + +.tags { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.tags span { + background: #e0f2fe; + color: #0369a1; + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; +} + +.edge { + position: absolute; + overflow: visible; + pointer-events: none; +} + +:root { + --mindmap-edge-color: #cbd5f5; +} + diff --git a/src/components/mindmap/shortcut/MindmapShortcutController.ts b/src/components/mindmap/shortcut/MindmapShortcutController.ts new file mode 100644 index 00000000..f6299d2f --- /dev/null +++ b/src/components/mindmap/shortcut/MindmapShortcutController.ts @@ -0,0 +1,58 @@ +import { useEffect } from 'react'; +import { mindmapCommandBus } from '@/lib/mindmap/command/MindmapCommandBus'; + +type ShortcutHandler = () => void; + +const shortcutMap = new Map([ + ['Enter', [() => mindmapCommandBus.emit('INSERT_CHILD_NODE', { parentId: '' })]], + ['Delete', [() => mindmapCommandBus.emit('REMOVE_NODE', { nodeId: '' })]], +]); + +let activeInstance: string | null = null; + +export interface MindmapShortcutControllerProps { + instanceId: string; + enable?: boolean; +} + +export function MindmapShortcutController({ instanceId, enable = true }: MindmapShortcutControllerProps) { + useEffect(() => { + if (!enable) return; + const onKeyDownCapture = (event: KeyboardEvent) => { + if (activeInstance && activeInstance !== instanceId) return; + if (!activeInstance) activeInstance = instanceId; + const combo = getKeyCombo(event); + const handlers = shortcutMap.get(combo); + if (!handlers) return; + event.stopPropagation(); + event.preventDefault(); + handlers.forEach((handler) => handler()); + }; + + document.addEventListener('keydown', onKeyDownCapture, true); + return () => { + document.removeEventListener('keydown', onKeyDownCapture, true); + if (activeInstance === instanceId) { + activeInstance = null; + } + }; + }, [instanceId, enable]); + + return null; +} + +export function registerShortcut(combo: string, handler: ShortcutHandler) { + const existing = shortcutMap.get(combo) ?? []; + existing.push(handler); + shortcutMap.set(combo, existing); +} + +function getKeyCombo(event: KeyboardEvent) { + const parts: string[] = []; + if (event.ctrlKey || event.metaKey) parts.push('Control'); + if (event.altKey) parts.push('Alt'); + if (event.shiftKey) parts.push('Shift'); + parts.push(event.key.length === 1 ? event.key.toUpperCase() : event.key); + return parts.join('+'); +} + diff --git a/src/components/mindmap/text/MindmapTextEditor.tsx b/src/components/mindmap/text/MindmapTextEditor.tsx new file mode 100644 index 00000000..0a0aec5f --- /dev/null +++ b/src/components/mindmap/text/MindmapTextEditor.tsx @@ -0,0 +1,35 @@ +import { useEffect, useRef } from 'react'; +import styles from './mindmap-text-editor.module.css'; + +export interface MindmapTextEditorProps { + value: string; + onChange(value: string): void; + onBlur?(): void; +} + +export function MindmapTextEditor({ value, onChange, onBlur }: MindmapTextEditorProps) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current && ref.current.innerText !== value) { + ref.current.innerText = value; + } + }, [value]); + + const handleInput = () => { + if (!ref.current) return; + onChange(ref.current.innerText); + }; + + return ( +
+ ); +} + diff --git a/src/components/mindmap/text/mindmap-text-editor.module.css b/src/components/mindmap/text/mindmap-text-editor.module.css new file mode 100644 index 00000000..720f811b --- /dev/null +++ b/src/components/mindmap/text/mindmap-text-editor.module.css @@ -0,0 +1,13 @@ +.editor { + min-width: 160px; + min-height: 48px; + outline: none; + border-radius: 16px; + padding: 8px 12px; + border: 1px solid rgba(148, 163, 184, 0.6); + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + font-size: 14px; + line-height: 1.4; +} + diff --git a/src/components/mindmap/text/useMindmapTextEdit.ts b/src/components/mindmap/text/useMindmapTextEdit.ts new file mode 100644 index 00000000..f0c48e6d --- /dev/null +++ b/src/components/mindmap/text/useMindmapTextEdit.ts @@ -0,0 +1,17 @@ +import { useState, useCallback } from 'react'; +import { mindmapCommandBus } from '@/lib/mindmap/command/MindmapCommandBus'; + +export function useMindmapTextEdit(nodeId: string) { + const [value, setValue] = useState(''); + + const handleChange = useCallback( + (next: string) => { + setValue(next); + mindmapCommandBus.emit('SET_NODE_TEXT', { nodeId, text: next }); + }, + [nodeId], + ); + + return { value, setValue, handleChange }; +} + diff --git a/src/lib/mindmap/command/MindmapCommandBus.ts b/src/lib/mindmap/command/MindmapCommandBus.ts new file mode 100644 index 00000000..d22121e4 --- /dev/null +++ b/src/lib/mindmap/command/MindmapCommandBus.ts @@ -0,0 +1,34 @@ +type Handler = (payload: T) => void; + +export interface CommandMap { + INSERT_NODE: { parentId?: string }; + INSERT_CHILD_NODE: { parentId: string }; + REMOVE_NODE: { nodeId: string }; + SET_NODE_TEXT: { nodeId: string; text: string }; + SET_NODE_LINK: { nodeId: string; link: unknown }; +} + +export type CommandName = keyof CommandMap; + +export class MindmapCommandBus { + private handlers: { [K in CommandName]?: Set> } = {}; + + on(command: K, handler: Handler) { + if (!this.handlers[command]) { + this.handlers[command] = new Set(); + } + this.handlers[command]!.add(handler); + return () => this.off(command, handler); + } + + off(command: K, handler: Handler) { + this.handlers[command]?.delete(handler); + } + + emit(command: K, payload: CommandMap[K]) { + this.handlers[command]?.forEach((handler) => handler(payload)); + } +} + +export const mindmapCommandBus = new MindmapCommandBus(); + diff --git a/src/lib/mindmap/layout/MindLayoutEngine.ts b/src/lib/mindmap/layout/MindLayoutEngine.ts new file mode 100644 index 00000000..0dad8be6 --- /dev/null +++ b/src/lib/mindmap/layout/MindLayoutEngine.ts @@ -0,0 +1,37 @@ +import { + LayoutEngine, + LayoutOptions, + LayoutResult, + LayoutName, + MindmapTree, +} from '@/lib/mindmap/types'; +import { LogicalStructureLayout } from '@/lib/mindmap/layout/engines/logicalStructure'; +import { MindLayout } from '@/lib/mindmap/layout/engines/mindMap'; + +type EngineRegistry = Record; + +export class MindLayoutEngine { + private engines: EngineRegistry; + private defaultOptions: LayoutOptions; + + constructor(options: LayoutOptions = {}) { + this.defaultOptions = options; + this.engines = { + logical: new LogicalStructureLayout(), + mind: new MindLayout(), + radial: new LogicalStructureLayout(), + timeline: new LogicalStructureLayout(), + }; + } + + register(name: LayoutName, engine: LayoutEngine) { + this.engines[name] = engine; + } + + compute(tree: MindmapTree, layout: LayoutName = 'logical', options?: LayoutOptions): LayoutResult { + const engine = this.engines[layout] ?? this.engines.logical; + const mergedOptions = { ...this.defaultOptions, ...options }; + return engine.compute(tree, mergedOptions); + } +} + diff --git a/src/lib/mindmap/layout/engines/logicalStructure.ts b/src/lib/mindmap/layout/engines/logicalStructure.ts new file mode 100644 index 00000000..777ff042 --- /dev/null +++ b/src/lib/mindmap/layout/engines/logicalStructure.ts @@ -0,0 +1,62 @@ +import { + LayoutEngine, + LayoutNode, + LayoutOptions, + LayoutResult, + MindmapNode, + MindmapTree, +} from '@/lib/mindmap/types'; + +interface PositionedNode { + node: MindmapNode; + depth: number; + index: number; +} + +export class LogicalStructureLayout implements LayoutEngine { + readonly name = 'logical' as const; + + compute(tree: MindmapTree, options: LayoutOptions = {}): LayoutResult { + const horizontalSpacing = options.horizontalSpacing ?? 280; + const verticalSpacing = options.verticalSpacing ?? 96; + + const positioned: PositionedNode[] = []; + walk(tree, 0, positioned); + + const nodes: LayoutNode[] = positioned.map((item) => ({ + id: item.node.id, + parentId: item.node.parentId, + depth: item.depth, + data: item.node.data, + position: { + x: item.depth * horizontalSpacing, + y: item.index * verticalSpacing, + }, + })); + + const edges = nodes + .filter((n) => n.parentId) + .map((n) => ({ + id: `${n.parentId}-${n.id}`, + source: n.parentId!, + target: n.id, + })); + + const bounds = { + width: Math.max(...nodes.map((n) => n.position.x), 0) + horizontalSpacing, + height: + Math.max(...nodes.map((n) => n.position.y), 0) + verticalSpacing, + }; + + return { nodes, edges, bounds }; + } +} + +function walk(node: MindmapNode, depth: number, acc: PositionedNode[], nextIndex = { value: 0 }) { + acc.push({ node, depth, index: nextIndex.value++ }); + node.children?.forEach((child) => { + child.parentId = node.id; + walk(child, depth + 1, acc, nextIndex); + }); +} + diff --git a/src/lib/mindmap/layout/engines/mindMap.ts b/src/lib/mindmap/layout/engines/mindMap.ts new file mode 100644 index 00000000..ce62a1b3 --- /dev/null +++ b/src/lib/mindmap/layout/engines/mindMap.ts @@ -0,0 +1,24 @@ +import { + LayoutEngine, + LayoutOptions, + LayoutResult, + MindmapTree, +} from '@/lib/mindmap/types'; +import { LogicalStructureLayout } from '@/lib/mindmap/layout/engines/logicalStructure'; + +/** + * Mind map layout currently reuses the logical structure strategy but keeps a + * distinct class so that we can introduce bezier/left-right balancing later. + */ +export class MindLayout implements LayoutEngine { + readonly name = 'mind' as const; + private delegate = new LogicalStructureLayout(); + + compute(tree: MindmapTree, options?: LayoutOptions): LayoutResult { + return this.delegate.compute(tree, { + horizontalSpacing: options?.horizontalSpacing ?? 320, + verticalSpacing: options?.verticalSpacing ?? 72, + }); + } +} + diff --git a/src/lib/mindmap/parser/linkParser.ts b/src/lib/mindmap/parser/linkParser.ts new file mode 100644 index 00000000..8c3b171e --- /dev/null +++ b/src/lib/mindmap/parser/linkParser.ts @@ -0,0 +1,88 @@ +import { + MindmapLink, + MindmapLinkDocument, + MindmapLinkMindNode, + MindmapLinkUrl, +} from '@/lib/mindmap/types'; + +const MINDMAP_PROTOCOL = 'mind://'; + +export interface ParseLinkResult { + link: MindmapLink | null; + raw: string; +} + +export function parseLink(input: string): ParseLinkResult { + const trimmed = input.trim(); + if (!trimmed) { + return { link: null, raw: input }; + } + + return ( + parseMindmapProtocol(trimmed) ?? + parseJsonLink(trimmed) ?? + parsePlainUrl(trimmed) ?? { link: null, raw: input } + ); +} + +function parseMindmapProtocol(text: string): ParseLinkResult | null { + if (!text.startsWith(MINDMAP_PROTOCOL)) return null; + const fragment = text.slice(MINDMAP_PROTOCOL.length); + const [mindmapId, nodeId] = fragment.split('/'); + if (!mindmapId || !nodeId) return null; + const link: MindmapLinkMindNode = { + type: 'mindmap-node', + mindmapId, + nodeId, + }; + return { link, raw: text }; +} + +function parseJsonLink(text: string): ParseLinkResult | null { + try { + const payload = JSON.parse(text); + if (payload?.type === 'document' && typeof payload.documentId === 'string') { + const link: MindmapLinkDocument = { + type: 'document', + documentId: payload.documentId, + title: payload.title, + }; + return { link, raw: text }; + } + return null; + } catch { + return null; + } +} + +function parsePlainUrl(text: string): ParseLinkResult | null { + try { + const url = new URL(text); + const link: MindmapLinkUrl = { + type: 'url', + url: url.toString(), + title: url.hostname, + }; + return { link, raw: text }; + } catch { + return null; + } +} + +export function serializeLink(link: MindmapLink | null): string { + if (!link) return ''; + switch (link.type) { + case 'document': + return JSON.stringify({ + type: 'document', + documentId: link.documentId, + title: link.title, + }); + case 'mindmap-node': + return `${MINDMAP_PROTOCOL}${link.mindmapId}/${link.nodeId}`; + case 'url': + return link.url; + default: + return ''; + } +} diff --git a/src/lib/mindmap/types.ts b/src/lib/mindmap/types.ts new file mode 100644 index 00000000..25187634 --- /dev/null +++ b/src/lib/mindmap/types.ts @@ -0,0 +1,113 @@ +/** + * Core shared mindmap types used across the Stage S1 implementation. + * These types intentionally avoid external library dependencies so that + * they can be consumed both on the server (Next.js route handlers) and + * the client (React components). + */ + +export type MindmapIdentifier = string; + +export type LayoutName = + | 'logical' + | 'mind' + | 'radial' + | 'timeline'; + +export interface MindmapNodeData { + title: string; + richText?: string; + icon?: string; + tags?: string[]; + link?: MindmapLink | null; + attachments?: MindmapAttachment[]; + collapsed?: boolean; + [key: string]: unknown; +} + +export interface MindmapNode { + id: MindmapIdentifier; + parentId?: MindmapIdentifier; + children?: MindmapNode[]; + data: MindmapNodeData; +} + +export type MindmapTree = MindmapNode; + +export interface MindmapLinkBlock { + type: 'block'; + blockId: string; + title?: string; +} + +export interface MindmapLinkDocument { + type: 'document'; + documentId: string; + title?: string; +} + +export interface MindmapLinkMindNode { + type: 'mindmap-node'; + mindmapId: string; + nodeId: string; + title?: string; +} + +export interface MindmapLinkPdf { + type: 'pdf'; + path: string; + id: string; + title?: string; + imageUrl?: string; +} + +export interface MindmapLinkUrl { + type: 'url'; + url: string; + title?: string; +} + +export type MindmapLink = + | MindmapLinkBlock + | MindmapLinkDocument + | MindmapLinkMindNode + | MindmapLinkPdf + | MindmapLinkUrl; + +export interface MindmapAttachment { + id: string; + name: string; + url: string; +} + +export interface LayoutNode { + id: MindmapIdentifier; + parentId?: MindmapIdentifier; + position: { x: number; y: number }; + depth: number; + data: MindmapNodeData; +} + +export interface LayoutEdge { + id: string; + source: MindmapIdentifier; + target: MindmapIdentifier; +} + +export interface LayoutResult { + nodes: LayoutNode[]; + edges: LayoutEdge[]; + bounds: { width: number; height: number }; +} + +export interface LayoutOptions { + nodeWidth?: number; + nodeHeight?: number; + horizontalSpacing?: number; + verticalSpacing?: number; +} + +export interface LayoutEngine { + readonly name: LayoutName; + compute(tree: MindmapTree, options?: LayoutOptions): LayoutResult; +} + diff --git a/src/store/useMindmapStore.ts b/src/store/useMindmapStore.ts new file mode 100644 index 00000000..9eef1e87 --- /dev/null +++ b/src/store/useMindmapStore.ts @@ -0,0 +1,44 @@ +import { useSyncExternalStore } from 'react'; + +type Listener = () => void; + +export interface MindmapState { + scale: number; + translate: { x: number; y: number }; + activeNodeIds: string[]; + multiRoot: boolean; +} + +type Setter = (updater: (state: T) => void) => void; + +function createStore(initialState: MindmapState) { + let state = initialState; + const listeners = new Set(); + + const setState: Setter = (updater) => { + updater(state); + listeners.forEach((listener) => listener()); + }; + + const getSnapshot = () => state; + + const subscribe = (listener: Listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + + return { setState, getSnapshot, subscribe }; +} + +const store = createStore({ + scale: 1, + translate: { x: 0, y: 0 }, + activeNodeIds: [], + multiRoot: false, +}); + +export function useMindmapStore(selector: (state: MindmapState) => T): [T, Setter] { + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + return [selector(snapshot), store.setState]; +} +