feat: scaffold mindmap core and layout engine
This commit is contained in:
@@ -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<string, LayoutResultNode>`,当节点数据未变化时复用位置。通过 `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<string, ShortcutEntry[]>`,默认注册:新增节点、删除、展开、直觉按钮等。
|
||||||
|
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 项目上依次打勾,并在备注表记录进度与提交。
|
||||||
+56
@@ -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 核心内核 | 初始化画布/布局/命令/快捷键/文本/链接骨架,形成可用工程模板 | 暂未提交 |
|
||||||
@@ -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<HTMLDivElement | null>(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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
className={[styles.canvas, className].filter(Boolean).join(' ')}
|
||||||
|
style={{ '--mindmap-scale': state.scale } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.scene}
|
||||||
|
style={{
|
||||||
|
width: combined.bounds.width,
|
||||||
|
height: combined.bounds.height,
|
||||||
|
transform: `scale(${state.scale}) translate(${state.translate.x}px, ${state.translate.y}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{combined.nodes.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className={[
|
||||||
|
styles.node,
|
||||||
|
state.activeNodeIds.includes(node.id) ? styles.nodeActive : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
style={{
|
||||||
|
left: node.position.x,
|
||||||
|
top: node.position.y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.title}>{node.data.title}</div>
|
||||||
|
{node.data.tags?.length ? (
|
||||||
|
<div className={styles.tags}>
|
||||||
|
{node.data.tags.map((tag) => (
|
||||||
|
<span key={tag}>{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{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 (
|
||||||
|
<svg key={edge.id} className={styles.edge}>
|
||||||
|
<line
|
||||||
|
x1={source.position.x + 120}
|
||||||
|
y1={source.position.y + 32}
|
||||||
|
x2={target.position.x}
|
||||||
|
y2={target.position.y + 32}
|
||||||
|
stroke="var(--mindmap-edge-color)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { mindmapCommandBus } from '@/lib/mindmap/command/MindmapCommandBus';
|
||||||
|
|
||||||
|
type ShortcutHandler = () => void;
|
||||||
|
|
||||||
|
const shortcutMap = new Map<string, ShortcutHandler[]>([
|
||||||
|
['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('+');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<HTMLDivElement | null>(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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={styles.editor}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onInput={handleInput}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
type Handler<T = unknown> = (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<Handler<CommandMap[K]>> } = {};
|
||||||
|
|
||||||
|
on<K extends CommandName>(command: K, handler: Handler<CommandMap[K]>) {
|
||||||
|
if (!this.handlers[command]) {
|
||||||
|
this.handlers[command] = new Set();
|
||||||
|
}
|
||||||
|
this.handlers[command]!.add(handler);
|
||||||
|
return () => this.off(command, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off<K extends CommandName>(command: K, handler: Handler<CommandMap[K]>) {
|
||||||
|
this.handlers[command]?.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends CommandName>(command: K, payload: CommandMap[K]) {
|
||||||
|
this.handlers[command]?.forEach((handler) => handler(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mindmapCommandBus = new MindmapCommandBus();
|
||||||
|
|
||||||
@@ -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<LayoutName, LayoutEngine>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<T> = (updater: (state: T) => void) => void;
|
||||||
|
|
||||||
|
function createStore(initialState: MindmapState) {
|
||||||
|
let state = initialState;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
const setState: Setter<MindmapState> = (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<T>(selector: (state: MindmapState) => T): [T, Setter<MindmapState>] {
|
||||||
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||||
|
return [selector(snapshot), store.setState];
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user