feat: 收口 mindmap Phase 6 Leptos UI shell

This commit is contained in:
lix-2026
2026-05-11 12:27:40 +08:00
parent b7dddd2a66
commit b5eb27fabc
51 changed files with 8669 additions and 1313 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,222 @@
# 6 [process] Mindmap leptos adapter reference notes v1
> 日期:2026-05-10
>
> 目的:记录 `lx-doc/mind-map` 与 KMind 插件审计结果,为 Phase 6 `leptos-mindmap` 的 simple-mind-map bridge、command bridge 和第一阶段 UI 复刻定界。
## 1. 参考入口
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/Edit.vue`
- 注册 `simple-mind-map` 插件。
- 初始化 `new MindMap({ el, data, layout, theme, themeConfig, viewData, config })`
- 承接 `execCommand`、关联线、图片上传、富文本、滚动条等 runtime 行为。
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/ToolbarNodeBtnList.vue`
- 提供节点编辑工具栏命令和禁用状态参考。
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/SidebarTrigger.vue`
- 提供右侧栏触发器参考。
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/config/zh.js`
- `sidebarTriggerList` 定义 `nodeStyle/baseStyle/theme/structure/outline/shortcutKey`
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/NavigatorToolbar.vue`
- 提供底部导航工具参考。
- `design/05-editor-mainline/reference-code/kmind-plugin/README.md`
- 提供 KMind 的思源耦合能力与延期边界参考。
## 2. 本轮需要注册的插件
第一阶段必须注册:
- `Drag`:画布拖拽、节点拖拽编辑的基础能力。
- `KeyboardNavigation`:方向键、基础键盘导航。
- `Export`:导出入口的基础能力,先保留 UI 与 runtime 能力,不把导出结果当 kernel truth。
- `Select`:框选、多选与 active node 状态。
- `AssociativeLine`:关联线渲染与创建入口。
- `Search`:底部搜索入口需要。
- `OuterFrame`:外框按钮与已有 KMind/lx-doc 视觉能力对齐。
第一阶段建议按能力开关注册:
- `RichText`:仅在节点富文本开启时加载;富文本 HTML 进入 `compatPayload`,同时保留纯文本到 kernel node text。
- `Scrollbar`:仅在配置开启时加载,避免小画布重复滚动 UI。
- `MiniMap`:底部按钮可以延期显示,但 bridge 应保留插件注册能力。
- `Painter`:格式刷属于 toolbar 第一阶段按钮,样式变更先走 `compatPayload.patch`
- `Formula`:公式先作为节点扩展字段进入 `compatPayload`
第一阶段不作为必须注册:
- `ExportPDF` / `ExportXMind`:导出格式能力可保留在独立页或后续任务,不阻塞 block 内编辑。
- `Watermark`:不属于当前 mnote 主体验。
- `TouchEvent`:移动端可用性后续补齐。
- `RainbowLines` / `Demonstrate` / `HandDrawnLikeStyle` / `Notation`:作为高级视觉或演示能力延期。
- `NodeImgAdjust`:图片字段先兼容保存,图片尺寸调整后续再语义化。
## 3. Toolbar 到 kernel command 的映射
直接发 kernel command
- `INSERT_CHILD_NODE` -> `mindmap.node.insert_child`
- `INSERT_NODE` -> `mindmap.node.insert_sibling_after`
- `REMOVE_NODE` -> `mindmap.node.delete`
- 节点文本提交 -> `mindmap.node.update_text`
- 节点拖拽换父或重排 -> `mindmap.node.move`
- 结构切换 -> `mindmap.layout.set`
- 主题切换 -> `mindmap.theme.set`
- 缩放、平移、只读状态以外的 view 变化 -> `mindmap.view.patch`
需要 bridge 或 diff 二次确认:
- `BACK` / `FORWARD`:第一阶段作为 runtime history 操作;后续若 kernel history 接管,再提升为 kernel undo/redo。
- `ADD_GENERALIZATION`:映射到 `mindmap.summary.set`,当前 Rust DTO 最小集未完全覆盖时先进入 `compatPayload.patch` 并记录字段路径。
- `createAssociativeLine`:映射到 `mindmap.associative_line.upsert`,当前 Rust DTO 最小集未完全覆盖时先进入 `compatPayload.patch`
- `ADD_OUTER_FRAME`:第一阶段进入 `compatPayload.patch`,后续提升为 outer frame kernel 语义。
第一阶段作为 compat 字段处理:
- 图片、图标、标签、超链接、备注、公式、附件、标记、格式刷产生的节点样式。
- 这些入口可以出现在 UI 中,但保存路径必须经过 command bridge;不可直接把整棵 runtime data 保存为 canonical truth。
## 4. 右侧栏第一阶段保留项
第一阶段保留:
- `nodeStyle`:节点样式。可编辑项先进入节点 `compatPayload`,基础文本仍回写 kernel。
- `baseStyle`:导图样式。概要、关联线、间距等暂未语义化字段进入 `compatPayload`
- `theme`:主题切换,能识别的主题名走 `mindmap.theme.set`
- `structure`:结构切换,走 `mindmap.layout.set`
- `outline`:大纲只作为 projection 派生视图,不创建第二套树真相。
第一阶段不放入右侧栏:
- `shortcutKey`:快捷键说明可以后续做文档或设置面板,不阻塞编辑器主链。
- KMind 全局配置、主题设计器、主题分享:后续独立配置任务处理。
## 5. 底部工具第一阶段保留项
第一阶段保留:
- 节点数 / 字数:从 adapter projection 或 runtime 派生,只读展示。
- 回根节点:调用 `mindMap.renderer.setRootNodeCenter()`,同时可记录 view patch。
- 搜索:调用 `Search` 插件或事件入口。
- 缩放百分比:监听 `scale`,通过 `view_data_change` / `mindmap.view.patch` 保存必要 view state。
- 全屏或只读:block 内优先保留只读切换;独立页可提供全屏。
第一阶段可延期:
- 鼠标行为高级配置。
- MiniMap 展示按钮。
- 暗色主题切换。
- 源码编辑。
- 演示模式。
- 多语言切换和帮助链接。
## 6. 必须进入 compatPayload 的字段
节点级字段:
- `image``imageTitle``imageSize`
- `icon`
- `tag`
- `hyperlink``hyperlinkTitle`
- `note`
- `richText`
- `generalization` 中尚未进入 kernel summary DTO 的私有样式字段
- `formula`
- `attachment`
- `notation`
- 节点自定义样式、内外边距、背景、字体、边框、图片调整状态
- `customLeft``customTop` 等自由布局坐标
导图级字段:
- `theme.config` 中无法映射到 kernel theme 的细项。
- `config` 中 runtime 私有行为配置,如鼠标行为、粘贴拆分、图片上传默认值。
- `view` 中无法映射到稳定 view DTO 的细项。
- `outerFrame`、高级关联线样式、概要样式。
- 插件私有状态,如 `MiniMap``Painter``Demonstrate``Notation`
保存约束:
- `compatPayload` 只能承接暂未语义化字段。
- 每次 `compatPayload.patch` 必须记录字段路径、来源事件和 kernel revision。
- 不允许把 `root` 整棵 runtime data 作为唯一成功保存结果。
## 7. KMind 专有能力延期
延期到 Phase 6 之后:
- 多根节点。
- MOC 模式。
- 文档树导图。
- 思源块拖拽、渲染、悬浮预览。
- 节点镜像块与导图镜像块。
- 思源 PDF 标注跳转与移除 PDF 关联。
- 思源全局搜索对接。
- 子文档快捷打开位置。
- KMind 全局配置、自动禅模式、默认主题与默认结构。
- 主题设计器、主题分享。
- 历史版本、固定历史版本、自动历史清理。
- Freemind / XMind 全量导入导出。
- 右键菜单中的思源专有导出、复制镜像块、复制节点为图片等高级项。
这些能力可以影响长期设计,但不能改变 Phase 6 的主合同:`simple-mind-map` 是 editor adapterRust kernel projection / command 才是事实源。
## 8. 历史自研 React UI 壳复用清单
> 2026-05-11 复核:历史 React UI 壳比 `/mnt/Data1T/mnote-rust/components/editor/blocks` 的简化宿主完整得多。下一阶段迁移应优先抽取历史 React 组件族的配置和 action 语义,不应继续在 NodeView 内手写按钮。
>
> 2026-05-11 执行记录:上述能力清单已转为 `mindmap-ui-schema.ts` / `mindmap-action-map.ts` / Leptos/Rust shell 的第一阶段输入;旧 React 组件族已补 legacy/reference/compat 注释,默认 3000 文档页不运行旧 React mindmap 主链。
### 8.1 Toolbar
| 分组 / action | 旧来源 | 旧 runtime 调用或意图 | active node | 第一阶段 |
|---|---|---|---|---|
| 工具栏元信息 | `wolai-frontend/src/components/editor/blocks/mindmapToolbarConfig.ts``NodeToolbarKey``FileToolbarKey``nodeToolbarOrder``fileToolbarOrder``nodeToolbarMeta``fileToolbarMeta` | 定义按钮 key、label、iconClass 和顺序 | 视 action | schema-only |
| toolbar UI 分组 | `MindmapToolbar.tsx``nodeHandlers``fileHandlers` | 左侧节点操作、右侧文件/导入/导出;能力由 props 注入 | 视 action | must |
| undo / redo | `MindmapBlock.tsx``handleUndo``handleRedo` | `mindmap.execCommand("BACK" / "FORWARD")` | 否 | must |
| 同级 / 子节点 | `MindmapBlock.tsx``handleSibling``handleChild` | `SET_NODE_ACTIVE``INSERT_NODE` / `INSERT_CHILD_NODE`;无 active 时 `ensureActiveBefore` 选 root | 是 | must |
| 删除 / 概要 / 关联线 / 外框 | `MindmapBlock.tsx``handleDelete``handleSummary``handleAssociativeLine``handleOuterFrame` | `REMOVE_NODE``ADD_GENERALIZATION``ADD_ASSOCIATIVE_LINE``ADD_OUTER_FRAME` | 是 | must |
| 图片 | `MindmapBlock.tsx``handleImage``handleImageConfirm` | 输入/上传图片后 `SET_NODE_IMAGE` + `SET_NODE_STYLES imgPlacement` | 是 | must |
| 超链接 / 标签 / 备注 / 附件 | `MindmapBlock.tsx``handleLink``handleTag``handleNoteConfirm``handleAttachment` | `SET_NODE_HYPERLINK``SET_NODE_TAG``SET_NODE_NOTE``SET_NODE_ATTACHMENT` | 是 | must |
| 图标 / 公式 | `MindmapBlock.tsx``handleIcon``handleFormula` | 打开 sidebar `icons` / `formula` | 是 | schema-only / defer |
| 格式刷 | `MindmapBlock.tsx``handlePainter` | `mindmap.painter.startPainter()` | 否,依赖 Painter 插件 | defer |
| 导入 / 新建 / 本地恢复 / 导出 / 删除导图 | `MindmapBlock.tsx``handleImport``handleDeleteMindmap` | `.json/.smm/.xmind/.mmap/.md` 导入;`setData``clearHistory``persistData`;导出 JSON/PNG/SVG/PDF/MD/TXT/XMind;删除走 `/api/mindmap/{docId}/{mindmapId}` | 否 | must |
### 8.2 Sidebar
| panel | 旧来源 | 旧 runtime 调用或意图 | active node | 第一阶段 |
|---|---|---|---|---|
| trigger 配置 | `mindmapSidebarConfig.ts``SidebarPanel``sidebarTriggers` | `style/base/theme/structure/outline/settings/icons/formula/note/ai` | 视 panel | schema-only |
| 触发条 UI | `MindmapSidebarTrigger.tsx``MindmapSidebarTrigger` | 右侧竖条,`onSelect(isActive ? null : target)` | 否 | must |
| panel 路由 | `MindmapSidebar.tsx``MindmapSidebar` switch | `activeTab` 分发到各 panel | 视 panel | must |
| 节点样式 | `MindmapSidebar.tsx``StylePanel` | `node.getStyle(prop,false)` 读取,`node.setStyle(prop,value)` 写入文字、边框、填充、连线样式 | 是 | must |
| 基础样式 | `MindmapSidebar.tsx``BaseStylePanel` | `mindmap.getThemeConfig` / `mindmap.setThemeConfig` | 否 | must |
| 主题 | `MindmapSidebar.tsx``ThemePanel` | `mindmap.setTheme(theme)` | 否 | must |
| 结构 | `MindmapSidebar.tsx``StructurePanel` | `mindmap.setLayout(layout)` | 否 | must |
| 图标/贴纸 | `MindmapSidebar.tsx``IconPanel` | 动态加载 simple-mind-map icons`node.setIcon``node.setImage` | 是 | must |
| 大纲 | `MindmapSidebar.tsx``OutlinePanel` | 从 `mindmap.renderer.renderTree._node` 遍历;点击后设置 active 并 `setRootNodeCenter` | 否 | must |
| 设置 | `MindmapSidebar.tsx``SettingsPanel` | `mindmap.updateConfig``mindmap.reRender``watermark.updateWatermark` | 否 | schema-only |
| 备注 | `MindmapSidebar.tsx``NotePanel` | 读取 active `note`,保存/清除走 `SET_NODE_NOTE` | 是 | must |
| 公式 / AI | `FormulaPanel``MindmapAiAgentPanel` | `INSERT_FORMULA` 或 AI panel | 是 / 部分 | defer |
### 8.3 Navigator / MiniMap / Count
| 能力 | 旧来源 | 旧 runtime 调用或意图 | active node | 第一阶段 |
|---|---|---|---|---|
| 宿主挂载 | `MindmapBlock.tsx` 全屏和内嵌 stage | 同时挂 `MindmapNavigator``MindmapCount``MindmapMiniMap``MindmapContextMenu` | 否 | must |
| 缩放 / 居中 / 只读 / 全屏 / 小地图按钮 | `MindmapNavigator.tsx``MindmapNavigator` | `view.enlarge``view.narrow``view.setScale``renderer.setRootNodeCenter``setMode("readonly"/"edit")``onToggleMiniMap``toggleFullscreen` | 否 | must |
| MiniMap | `MindmapMiniMap.tsx``MindmapMiniMap` | `mindmap.miniMap.calculationMiniMap`;监听 `data_change/view_data_change/node_tree_render_end/mini_map_view_box_position_change` | 否 | must |
| Count | `MindmapCount.tsx``MindmapCount` | `mindmap.getData()` + `data_change`,递归统计节点数和去 HTML 文本长度 | 否 | must |
### 8.4 Context Menu
| action / 组 | 旧来源 | 旧 runtime 调用或意图 | active node | 第一阶段 |
|---|---|---|---|---|
| 菜单项配置 | `MindmapContextMenu.tsx``NODE_MENU_ITEMS` | 插入、移动、展开、删除、复制剪切粘贴、移除属性、导出节点、AI 续写 | 是 | schema-only |
| 禁用规则 | `MindmapContextMenu.tsx``isItemDisabled` | root/generalization 禁用部分插入、复制剪切、移动;首尾节点禁用上移/下移 | 是 | must |
| 捕获右键目标 | `MindmapContextMenu.tsx` contextmenu listener | 监听 `mindmap-canvas`,目标取 `renderer.activeNodeList[0] ?? lastActiveNodeList[0]` | 是 | must |
| 插入/移动/删除等默认命令 | `MindmapContextMenu.tsx``executeCommand` default | 多数 action 直接 `mindmap.execCommand(key)` | 是 | must |
| 复制/剪切/粘贴 | `MindmapContextMenu.tsx` | `mindmap.renderer.copy/cut/paste` | 是 | must |
| 移除超链接/备注 | `MindmapContextMenu.tsx` | `targetNode.setHyperlink("", "")``targetNode.setNote("")` | 是 | must |
| 展开/收起下级 | `MindmapContextMenu.tsx` | `UNEXPAND_ALL``EXPAND_ALL` | 是 | must |
| 导出当前节点 PNG / AI 续写 | `MindmapContextMenu.tsx` | `doExport.export("png", ..., targetNode)``mindmap-ai-continue` custom event | 是 | defer |
`/mnt/Data1T/mnote-rust/components/editor/blocks` 只保留基础 toolbar、theme/structure/view sidebar 和简化 runtime 宿主,可作为 adapter 最小结构参考,但不能替代当前仓旧 React UI 壳的完整能力清单。
@@ -0,0 +1,484 @@
# Mindmap Phase 6 leptos-mindmap UI Shell Reuse Checklist v1
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` for independent code slices, or `superpowers:executing-plans` when executing this checklist in order. Steps use checkbox (`- [ ]`) syntax for tracking. 每完成一个节点,必须基于代码、截图或测试证据勾选对应项。
**Goal:** 在不恢复旧 React 主链、不直接嵌入 lx-doc Vue 应用的前提下,把当前 `leptos-mindmap + simple-mind-map runtime` 从“无头 runtime + 手写 chrome”推进到接近 KMind/lx-doc 可用度的 floating overlay canvas UI shell。
**Architecture:** Rust kernel 继续持有导图事实源和命令语义,`simple-mind-map` 继续作为编辑 runtime。UI schema 只描述 toolbar、sidebar、navigator、context menu 的项目和动作,不负责布局;默认宿主必须采用接近 lx-doc/KMind 的画布承载模型:runtime 作为块内无限画布底层,toolbar/sidebar/navigator/count/minimap 作为 overlay 浮在画布上,不挤压画布尺寸。当前 TypeScript NodeView 只是过渡桥接层,后续 UI shell 应迁到 Leptos/Rust 组件,避免引入更多长期前端语言/框架边界。
**Tech Stack:** Rust `core-protocol` / `bridge-runtime` / `mnote-web``leptos-tiptap` NodeView、`simple-mind-map`、历史 React `MindmapBlock` 组件族、lx-doc `mind-map` 参考代码、Playwright smoke。
---
## 0. 当前判断
当前 Phase 6 的方向不需要改成“直接嵌入 lx-doc/mind-map”。应该继续采用:
```text
Rust kernel truth
-> mindmap.simple_mind_map_scene.v1
-> leptos-tiptap NodeView
-> simple-mind-map runtime
-> UI shell schema / action bridge
-> kernel command / compatPayload.patch
```
本 checklist 修正的是下一阶段执行重心:
- 不再把“接入 `simple-mind-map` runtime”当作可用导图编辑器完成。
- 不再继续手写一次性的功能键、选项栏和底部栏。
- 优先复用本仓历史自研 UI 壳的配置、按钮分组、右侧栏、导航栏、MiniMap、统计、右键菜单经验。
- lx-doc/KMind 作为视觉、命令和交互基线;历史 React 组件作为最直接的迁移素材。
- 2026-05-11 修正:此前 schema UI 已能显示工具项,但仍采用 grid/flex 文档流布局,和 `https://wanglin2.github.io/mind-map/#/`、KMind 截图的“无限画布 + 浮动 UI”不一致。后续必须先迁移到 floating overlay canvas shell,再继续扩展功能语义。
- 2026-05-11 方向约束:mnote 主体是 Rust,`leptos-tiptap` 也是嵌入式 Leptos 路线;mindmap UI shell 不应继续沉淀为 TypeScript DOM UI。TS 只保留 `tiptap NodeView mount + simple-mind-map bridge + event/command forwarding`,真正的 toolbar/sidebar/navigator/context menu 应迁移到 Leptos/Rust。
## 1. 参考材料
### 当前主线文档
- `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- `design/06-mindmap/process/6-mindmap-leptos-adapter-reference-notes-v1.md`
- `docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md`
### lx-doc / KMind 参考
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/Edit.vue`
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/ToolbarNodeBtnList.vue`
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/SidebarTrigger.vue`
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/NavigatorToolbar.vue`
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/config/zh.js`
- `design/05-editor-mainline/reference-code/kmind-plugin`
### 本仓历史 UI 壳
- `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- `wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx`
- `wolai-frontend/src/components/editor/blocks/mindmapToolbarConfig.ts`
- `wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`
- `wolai-frontend/src/components/editor/blocks/mindmapSidebarConfig.ts`
- `wolai-frontend/src/components/editor/blocks/MindmapNavigator.tsx`
- `wolai-frontend/src/components/editor/blocks/MindmapMiniMap.tsx`
- `wolai-frontend/src/components/editor/blocks/MindmapCount.tsx`
- `wolai-frontend/src/components/editor/blocks/MindmapContextMenu.tsx`
### 历史简化宿主参考
- `/mnt/Data1T/mnote-rust/components/editor/blocks/MindmapRuntimeEditor.tsx`
- `/mnt/Data1T/mnote-rust/components/editor/blocks/MindmapToolbar.tsx`
- `/mnt/Data1T/mnote-rust/components/editor/blocks/MindmapNavigator.tsx`
- `/mnt/Data1T/mnote-rust/components/editor/blocks/MindmapMiniMap.tsx`
- `/mnt/Data1T/mnote-rust/components/editor/blocks/MindmapSidebarTrigger.tsx`
## 2. 文件职责图
- `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
- 新增:toolbar、sidebar、navigator、context menu 的中立 UI schema。
- `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
- 新增:UI action 到 `simple-mind-map` command、kernel command、`compatPayload.patch` 的映射。
- `wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts`
- 新增:active node、selection、readonly、scale、panel open state、disabled state 的派生逻辑。
- `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- 修改:暴露 UI shell 需要的 runtime action、state subscribe、command result。
- `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts`
- 修改:补齐 `execCommand`、插件能力探测、scale/view 操作、销毁清理。
- `wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts`
- 修改:补齐 toolbar/sidebar/context menu 产生的变更到 kernel command / compat patch 的转换。
- `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`
- 过渡修改:NodeView 只负责挂载 runtime、Leptos/Rust UI shell 宿主和最小桥接,不再继续扩散硬编码按钮逻辑。
- `rust/spikes/leptos-tiptap-spike/generated/island/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js`
- 修改:同步生成后的 browser-side bridge,保持与 TS source 行为一致。
- `scripts/task166-mindmap-phase6-block-smoke.js`
- 修改:从 runtime 存在 smoke 升级为 UI shell、交互、保存、reload、截图 smoke。
### Floating overlay canvas shell 约束
- 默认 DOM 应为 `canvas-layer + overlay-layer``simple-mind-map` runtime 充满块内画布,toolbar/sidebar/navigator/count/minimap 都位于 overlay layer。
- toolbar 顶部居中浮动,视觉上接近 lx-doc `Toolbar.vue` 的 fixed toolbar,而不是文档流顶部栏。
- sidebar trigger / panel 右侧浮动,打开 panel 时不改变 runtime 宽度,只覆盖画布右侧安全区域。
- navigator 右下浮动,count 左下浮动,MiniMap 后续在右下 navigator 上方浮动。
- smoke 必须断言 overlay layer 存在,runtime rect 与 editor root rect 高度/宽度接近,不因 sidebar/toolbar/navigator 被 grid/flex 挤压。
### Leptos/Rust UI shell 收口约束
- `mindmap-ui-schema.ts` / `mindmap-action-map.ts` / `mindmap-ui-state.ts` 当前是过渡资产;新增长期 UI 行为时应优先设计 Rust/Leptos 可消费的 schema 和 signal/state 边界。
- `tiptap_paragraph.ts` 不应继续成为 toolbar/sidebar/navigator 的长期实现文件;新增复杂 UI 面板前,应先迁出到 Leptos/Rust 组件或生成的 island 组件边界。
- 禁止恢复旧 React `MindmapBlock.tsx` 为默认主链,禁止直接嵌入 lx-doc Vue app,禁止新增第三套 JS UI 框架。
- `simple-mind-map` 可以继续作为 runtime engine,但 UI shell 的宿主语言方向应是 Leptos/Rust。
---
### Task 1: 冻结新阶段边界
**Files:**
- Modify: `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- Modify: `docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md`
- Create/Modify: `design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- [x] 明确当前 Phase 6 不是“Rust 重写 lx-doc/mind-map”,也不是“直接嵌入 lx-doc Vue app”。
- 2026-05-11 记录:本文件“当前判断”和总设计稿第 3 节均保留三路线定位,Phase 6 主线为 `leptos-mindmap`
- [x] 明确下一阶段目标是“当前 simple-mind-map runtime + 可复用 UI shell + kernel command bridge”。
- 2026-05-11 记录:本文件 Goal/Architecture 与总设计稿 `10.2` 均明确 floating overlay UI shell + command bridge。
- [x] 在旧 checklist 中标记:已完成项如果只覆盖 runtime 接入和最小 smoke,不代表 KMind-like UI 可用性完成。
- 2026-05-11 更新:旧 checklist 新增 `Task 13: UI Shell Reuse 后续收口`,指向本文件。
- [x] 在设计稿中补一段“UI shell 复用策略”,指向本 checklist。
- 2026-05-11 更新:`6-mindmap-kernel-phase6-projection-editor-v1.md` 新增 `10.2 2026-05-11 UI shell reuse 收口状态`
- [x] 验证:`rg -n "UI shell|MindmapBlock.tsx|mindmap-ui-schema|直接嵌入 lx-doc" design/06-mindmap docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md`
- 2026-05-11 通过:命令能检索到本 checklist、总设计稿、reference notes 和旧执行 checklist 的对应口径。
### Task 2: 抽取历史 React UI 能力清单
**Files:**
- Inspect: `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- Inspect: `wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx`
- Inspect: `wolai-frontend/src/components/editor/blocks/mindmapToolbarConfig.ts`
- Inspect: `wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`
- Inspect: `wolai-frontend/src/components/editor/blocks/mindmapSidebarConfig.ts`
- Inspect: `wolai-frontend/src/components/editor/blocks/MindmapNavigator.tsx`
- Inspect: `wolai-frontend/src/components/editor/blocks/MindmapMiniMap.tsx`
- Inspect: `wolai-frontend/src/components/editor/blocks/MindmapCount.tsx`
- Inspect: `wolai-frontend/src/components/editor/blocks/MindmapContextMenu.tsx`
- Output: `design/06-mindmap/process/6-mindmap-leptos-adapter-reference-notes-v1.md`
- [x] 列出历史 toolbar 分组:历史操作、节点操作、插入扩展、样式、结构、导入导出、视图操作。
- [x] 列出每个 toolbar action 的旧实现来源、旧 runtime 调用、禁用条件、是否需要 active node。
- [x] 列出历史 sidebar panel:节点样式、基础样式、主题、结构、大纲、快捷键或设置。
- [x] 列出历史 navigator 能力:缩放、回根、适应画布、MiniMap、节点数/字数、只读/全屏。
- [x] 列出历史 context menu 能力:插入、删除、复制、粘贴、展开折叠、概要、关联线、样式。
- [x] 标注哪些能力第一阶段必须迁移,哪些只保留 schema 但暂不显示。
- [x] 验证:输出表格至少覆盖 toolbar、sidebar、navigator、context menu 四类能力,并能追溯到具体文件名。
- 2026-05-11 输出:`design/06-mindmap/process/6-mindmap-leptos-adapter-reference-notes-v1.md` 第 8 节。
### Task 3: 建立中立 UI Schema
**Files:**
- Create: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
- Create: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.test.ts`
- [x] 定义 `MindmapUiActionId`,覆盖第一阶段 action`undo``redo``editNode``insertSiblingAfter``insertChild``deleteNode``tag``hyperlink``note``image``icon``summary``associativeLine``formula``painter``import``export``setTheme``setLayout``zoomIn``zoomOut``fitView``centerRoot``search``readonly`
- [x] 定义 `MindmapToolbarGroup`,字段包含 `id``label``actions``collapsePriority`
- [x] 定义 `MindmapSidebarPanel`,字段包含 `id``label``icon``runtimeCapability``phase`
- [x] 定义 `MindmapNavigatorItem`,字段包含 `id``label``actionId``readOnly``displayMode`
- [x] 定义 `MindmapContextMenuItem`,字段包含 `id``label``actionId``requiresNode``phase`
- [x] 导出 `mindmapDefaultUiSchema`,只包含第一阶段需要显示的功能。
- [x] 单测断言 action id 不重复、每个 toolbar action 都能在 action map 中找到占位映射、第一阶段 panel 数量不超过 5 个。
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-ui-schema`
### Task 4: 建立 Action Map 与禁用状态
**Files:**
- Create: `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
- Create: `wolai-frontend/src/lib/mindmap/mindmap-action-map.test.ts`
- Create: `wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts`
- Create: `wolai-frontend/src/lib/mindmap/mindmap-ui-state.test.ts`
- Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- [x] 定义 `MindmapActionTarget``runtimeCommand``kernelCommand``compatPatch``localView`
- [x]`insertChild` 映射到 `simple-mind-map` 插入子节点命令,并在成功后走 `mindmap.node.insert_child` 或 diff。
- [x]`insertSiblingAfter` 映射到同级插入命令,并在成功后走 `mindmap.node.insert_sibling_after` 或 diff。
- [x]`deleteNode` 映射到删除命令,并在成功后走 `mindmap.node.delete` 或 diff。
- [x]`centerRoot``zoomIn``zoomOut``fitView` 映射为 runtime view action,同时产生 `mindmap.view.patch`
- [x]`setTheme``setLayout` 映射为 `mindmap.theme.set``mindmap.layout.set`
- [x]`tag``hyperlink``note``image``icon``formula``painter` 第一阶段映射为 `compatPayload.patch`,并记录字段路径。
- [x] 实现 `deriveMindmapUiState()`,根据 active node、readonly、runtime capabilities、selection 派生 disabled state。
- [x] 单测覆盖:无 active node 时节点操作禁用;readonly 时编辑类 action 禁用;view action 不依赖 active node。
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-action-map mindmap-ui-state`
- 2026-05-11 补充验证:`pnpm test -- mindmap-ui-schema mindmap-action-map mindmap-ui-state` 通过。
### Task 5: 替换 NodeView 中的硬编码 Chrome
**Files:**
- Modify: `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`
- Modify: `rust/spikes/leptos-tiptap-spike/generated/island/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js`
- Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- [x] 移除默认主链中的手写 toolbar button 列表,改为从 `mindmapDefaultUiSchema.toolbarGroups` 渲染。
- 2026-05-11 验证:默认 DOM `data-testid="mindmap-schema-toolbar"``data-schema-source="mindmapDefaultUiSchema"`;截图 `tmp/task166-mindmap-phase6-block-smoke/toolbar-actions.png`
- [x] 移除默认主链中的手写 sidebar trigger 列表,改为从 `mindmapDefaultUiSchema.sidebarPanels` 渲染。
- 2026-05-11 验证:默认 DOM `data-testid="mindmap-schema-sidebar"``data-schema-source="mindmapDefaultUiSchema"`;截图 `tmp/task166-mindmap-phase6-block-smoke/sidebar-node-style.png`
- [x] 移除默认主链中的固定 bottom bar 文案,改为从 navigator schema 和 runtime state 渲染。
- 2026-05-11 验证:默认 DOM `data-testid="mindmap-schema-navigator"`,显示节点数、字数与缩放百分比;截图 `tmp/task166-mindmap-phase6-block-smoke/navigator-default.png`
- [x] 保留最小错误层:`projection_load_failed``adapter_init_failed``runtime_render_failed``command_failed`
- [x] 保留 debug fallback,但必须通过显式 debug flag 才能显示,不作为默认主链。
- [x] 确保 NodeView 销毁时仍调用 adapter `destroy()`
- [x] 验证:页面 DOM 中默认不再出现旧 `.mnote-mindmap-node` 手写节点主链;`data-testid="mnote-mindmap-editor-root"` 仍存在。
- 2026-05-11 验证:`node scripts/task166-mindmap-phase6-block-smoke.js` 通过;`tmp/task166-mindmap-phase6-block-smoke/result.json``ok=true`,默认 `debugChrome=false` 且无手写 toolbar/sidebar/bottom bar。
### Task 5A: 修正为 Floating Overlay Canvas Shell
**Files:**
- Modify: `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`
- Modify: `rust/spikes/leptos-tiptap-spike/src/lib.rs`
- Modify: `rust/spikes/leptos-tiptap-spike/generated/island/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js`
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- [x] NodeView 默认 DOM 改为 `canvas-layer + overlay-layer`runtime 不再和 sidebar 组成 grid。
- 2026-05-11 实现:默认路径挂载 `mindmap-canvas-layer``mindmap-overlay-layer`debug fallback 仍保留旧布局。
- [x] toolbar 顶部居中浮动,sidebar 右侧浮动,navigator 右下浮动,count 左下浮动。
- 2026-05-11 截图:`tmp/task166-mindmap-phase6-block-smoke/01-after-insert-floating-overlay.png`
- [x] sidebar 打开或切换 panel 时不改变 runtime 宽度。
- 2026-05-11 验证:sidebar 已从 workspace grid 移到 overlay layersmoke 覆盖 panel 切换截图。
- [x] smoke 断言 `data-testid="mindmap-overlay-layer"` 存在,且 `runtimeBox.width / rootBox.width >= 0.9`
- 2026-05-11 验证:`node scripts/task166-mindmap-phase6-block-smoke.js` 通过。
- [x] smoke 保存 `01-after-insert-floating-overlay.png`,截图能看到画布底层和浮动 UI。
- 2026-05-11 截图:`tmp/task166-mindmap-phase6-block-smoke/01-after-insert-floating-overlay.png`
- [x] 验证:`cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/leptos-tiptap/tiptap && npm run typecheck && npm run build`
- 2026-05-11 通过。
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && node scripts/build-leptos-tiptap-island.js`
- 2026-05-11 已重新生成 island。
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task166-mindmap-phase6-block-smoke.js`
- 2026-05-11 通过;`tmp/task166-mindmap-phase6-block-smoke/result.json``ok=true`
### Task 5B: 收口到 Leptos/Rust UI Shell 宿主
**Files:**
- Modify/Create: `rust/spikes/leptos-tiptap-spike/src/lib.rs`
- Modify/Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell.rs` 或同等 Leptos 组件文件
- Modify: `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`
- Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- [x] 明确 `tiptap_paragraph.ts` 只创建 mount 容器、加载 runtime bridge、转发事件,不再直接渲染 toolbar/sidebar/navigator 长期 UI。
- 2026-05-11 实现:默认路径只挂载 `mindmap-rust-shell-mount`TS 负责 `simple-mind-map` runtime、projection/command bridge、`mnote:mindmap-shell:*` 事件转发;debug fallback 仍需显式 debug flag。
- [x] 将 floating overlay shell 的 toolbar/sidebar/count/navigator 渲染迁到 Leptos/Rust 组件边界。
- 2026-05-11 实现:`rust/spikes/leptos-tiptap-spike/src/lib.rs` 导出 `mount_mindmap_shell` / `unmount_mindmap_shell`Leptos 组件输出 `mindmap-rust-shell`、toolbar、sidebar、count、navigator。
- [x] Leptos/Rust UI shell 消费同等 schema/state/action 能力;若仍需 TS schema,必须作为临时 adapter 输入,不作为长期事实源。
- 2026-05-11 实现:当前 TS adapter 将 `mindmapDefaultUiSchema``deriveMindmapUiState()` 转为 Rust shell options;后续新增长期 UI 能力应继续迁到 Rust/Leptos schema 边界。
- [x] Leptos/Rust UI shell 触发 action 时仍经由 adapter command bridge,不绕过 kernel command。
- 2026-05-11 验证:Rust shell 派发 `mnote:mindmap-shell:action` / `mnote:mindmap-shell:panel`NodeView 统一转到 `executeSchemaAction()``applyCommandsAndRefresh()``patchView`
- [x] smoke 断言默认 UI shell 来源为 `leptos-rust-shell` 或等价稳定标记,而不是 `typescript-nodeview-dom`
- 2026-05-11 验证:`scripts/task166-mindmap-phase6-block-smoke.js` 断言 `data-ui-shell-source="leptos-rust-shell"``data-ui-source="leptos-rust-shell"`
- [x] 验证:`cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/leptos-tiptap/tiptap && npm run typecheck && npm run build`
- 2026-05-11 通过。
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && node scripts/build-leptos-tiptap-island.js`
- 2026-05-11 通过,island 入口包含 `mount_mindmap_shell` / `unmount_mindmap_shell`
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task166-mindmap-phase6-block-smoke.js`
- 2026-05-11 通过;`tmp/task166-mindmap-phase6-block-smoke/result.json``ok=true`,默认入口 `http://127.0.0.1:3000`;截图 `tmp/task166-mindmap-phase6-block-smoke/01-after-insert-floating-overlay.png`
### Task 6: Toolbar 第一阶段迁移
**Files:**
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
- Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- [x] 顶部 toolbar 显示:撤销、重做、同级节点、子节点、删除、标签、超链接、备注、图片、图标、概要、关联线、公式、格式刷、导入、导出。
- 2026-05-11 验证:`node scripts/task166-mindmap-phase6-block-smoke.js` 断言上述 action id 均存在;截图 `tmp/task166-mindmap-phase6-block-smoke/toolbar-actions.png`
- [x] toolbar 分组、图标尺寸、按钮间距对齐 KMind/lx-doc 的工作台密度,不做大卡片式按钮。
- 2026-05-11 实现:`rust/spikes/leptos-tiptap-spike/src/lib.rs` 新增 schema toolbar group、tool icon/label 紧凑样式;截图 `tmp/task166-mindmap-phase6-block-smoke/01-after-insert-schema-ui.png`
- [x] active node 为空时禁用节点相关按钮。
- 2026-05-11 实现:Rust shell 消费 `deriveMindmapUiState()` 派生的 disabled state;节点类 action 依赖 `activeNodeId`
- [x] readonly 时禁用所有编辑类按钮,保留缩放、搜索、回根。
- 2026-05-11 验证:`node scripts/task166-mindmap-phase6-block-smoke.js``verifyReadonlyToolbarState()` 覆盖 readonly 开关后编辑按钮禁用、view 按钮保留可用。
- [x] 点击“子节点”后真实插入节点,截图可见新增节点。
- 2026-05-11 验证:`toolbarActions.insertChildOk=true`,截图 `tmp/task166-mindmap-phase6-block-smoke/03-after-toolbar-actions.png`
- [x] 点击“同级节点”后真实插入同级节点,截图可见新增节点。
- 2026-05-11 验证:`toolbarActions.insertSiblingOk=true`,节点数从 5 增至 6。
- [x] 点击“删除”后真实删除选中节点,并能 reload 后保持。
- 2026-05-11 验证:`toolbarActions.deleteOk=true`,删除后节点数为 5,reload 后仍为 5。
- [x] 验证:smoke 输出 toolbar action 结果 JSON,包含 `insertChildOk``insertSiblingOk``deleteOk`
- 2026-05-11 通过:`tmp/task166-mindmap-phase6-block-smoke/result.json``toolbarActions={insertChildOk:true,insertSiblingOk:true,deleteOk:true}`
### Task 7: Sidebar 第一阶段迁移
**Files:**
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
- Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- [x] 右侧栏触发器显示:节点样式、导图样式、主题、结构、大纲。
- 2026-05-11 验证:smoke 断言 `nodeStyle``baseStyle``theme``structure``outline` 均存在。
- [x] 打开一个 panel 时不挤压或遮挡主要画布节点。
- 2026-05-11 验证:Task 5A/5B smoke 断言 `runtimeBox.width / rootBox.width >= 0.9``runtimeBox.height / rootBox.height >= 0.85`sidebar 位于 floating overlay,不改变 runtime 尺寸。
- [x] 主题 panel 第一阶段至少提供默认主题和 KMind-like 主题切换。
- 2026-05-11 实现:Rust/Leptos sidebar body 渲染 `theme-classic``theme-classic4`NodeView bridge 将选择转为 `setTheme` kernel command。验证:`node scripts/task166-mindmap-phase6-block-smoke.js``sidebarActions.themeOk=true`reload 后 `theme=classic4` 保持;截图 `tmp/task166-mindmap-phase6-block-smoke/sidebar-theme.png``sidebar-actions.png`
- [x] 结构 panel 第一阶段至少提供逻辑结构、右侧结构、鱼骨或树形结构中的可用子集。
- 2026-05-11 实现:Rust/Leptos sidebar body 渲染 `logicalStructure``mindMap``fishbone` 结构选项;NodeView bridge 将选择转为 `setLayout` kernel command。验证:`node scripts/task166-mindmap-phase6-block-smoke.js``sidebarActions.layoutOk=true`reload 后 `layout=mindMap` 保持;截图 `tmp/task166-mindmap-phase6-block-smoke/sidebar-structure.png`
- [x] 大纲 panel 只从 projection/runtime 派生,不创建第二份树真相。
- 2026-05-11 实现:NodeView 每次 mount Rust shell 时从当前 runtime snapshot / projection root 派生 `bodyItems`,Rust shell 只渲染传入列表,不维护第二份树状态。验证:`sidebarActions.outlineDerivedOk=true`,输出 outline items 包含当前导图节点文本;截图 `tmp/task166-mindmap-phase6-block-smoke/sidebar-outline.png`
- [x] 节点样式和导图样式的暂未语义化字段走 `compatPayload.patch`
- 2026-05-11 实现:sidebar option 派发 `source=sidebar``compatPath`;节点样式写入 `nodes.$active.data.fillColor`,导图样式写入 `compatPayload.style.map.lineStyle`。验证:`sidebarActions.nodeStyleCompatOk=true``sidebarActions.baseStyleCompatOk=true`reload 后保持;截图 `tmp/task166-mindmap-phase6-block-smoke/sidebar-node-style.png``sidebar-base-style.png`
- [x] 验证:smoke 能打开每个 panel,并保存 `sidebar-node-style.png``sidebar-theme.png``sidebar-structure.png``sidebar-outline.png`
- 2026-05-11 通过:`node scripts/task166-mindmap-phase6-block-smoke.js` 保存四张 sidebar 截图。
### Task 8: Navigator / MiniMap / Count 迁移
**Files:**
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
- Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- [x] 底部栏显示节点数、字数、当前缩放百分比。
- 2026-05-11 验证:smoke 断言 navigator stats 包含“节点”,zoom 包含 `%`;截图 `tmp/task166-mindmap-phase6-block-smoke/navigator-default.png`
- [x] “回根节点”调用 runtime center root,并写入 view patch。
- 2026-05-11 验证:`node scripts/task166-mindmap-phase6-block-smoke.js` 通过;`navigatorActions.centerRootOk=true`,通过浮动 navigator 按钮触发并观测到 `patchView` POST。
- [x] 缩放加减按钮调用 runtime zoom,并写入 view patch。
- 2026-05-11 验证:`navigatorActions.zoomOk=true``viewScale=1.2`,截图 `tmp/task166-mindmap-phase6-block-smoke/navigator-zoomed.png`
- [x] 搜索入口调用 `simple-mind-map` Search 插件或当前 adapter 搜索入口。
- 2026-05-11 实现:Rust/Leptos navigator search input 派发 `search` actionNodeView adapter 记录当前搜索入口状态与 query;后续可替换为 Search 插件面板。验证:`navigatorActions.searchOk=true`
- [x] MiniMap 第一阶段可折叠显示,但按钮和容器状态必须稳定。
- 2026-05-11 实现:Rust/Leptos shell 增加 MiniMap 开关和稳定 overlay 容器;不恢复旧 React MiniMap。验证:`navigatorActions.minimapOk=true`,截图 `tmp/task166-mindmap-phase6-block-smoke/navigator-minimap.png`
- [x] 底部栏在窄屏下不覆盖节点编辑区域。
- 2026-05-11 验证:当前 smoke 断言 runtime 仍保持 `runtimeBox.width/rootBox.width >= 0.9``runtimeBox.height/rootBox.height >= 0.85`navigator/MiniMap 均在 overlay layer,不改变 runtime 尺寸。
- [x] 验证:smoke 保存 `navigator-default.png``navigator-zoomed.png``navigator-minimap.png`
- 2026-05-11 通过:`node scripts/task166-mindmap-phase6-block-smoke.js` 输出 `ok=true`,截图路径包含三张 navigator 截图。
### Task 9: Context Menu 迁移
**Files:**
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
- Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- [x] 右键节点时显示节点 context menu。
- 2026-05-11 实现:NodeView capture 阶段监听 `contextmenu`,检测 `.smm-node` 后打开节点菜单。验证:`node scripts/task166-mindmap-phase6-block-smoke.js``contextMenuActions.nodeMenuOk=true`;截图 `tmp/task166-mindmap-phase6-block-smoke/context-menu-actions.png`
- [x] 右键画布时显示画布 context menu,节点专属操作禁用或隐藏。
- 2026-05-11 验证:smoke 右键 runtime 空白区后 `contextMenuActions.canvasMenuOk=true`,且 canvas 菜单不包含 `insertChild`
- [x] 节点 menu 第一阶段包含:插入子节点、插入同级节点、删除、展开/收起、概要、关联线、复制文本。
- 2026-05-11 验证:smoke 断言节点菜单包含 `insertChild``insertSiblingAfter``deleteNode``summary``associativeLine``expandCollapse``copyNodeText``copyTextOk=true``insertChildOk=true`
- [x] 画布 menu 第一阶段包含:回根节点、适应画布、搜索、只读切换。
- 2026-05-11 验证:smoke 断言画布菜单包含 `centerRoot``fitView``search``readonly`
- [x] 所有 context menu action 复用 `mindmap-action-map.ts`,不新增第二套动作逻辑。
- 2026-05-11 实现:`mindmap-ui-schema.ts` 新增 `expandCollapse``copyNodeText``mindmap-action-map.ts` 将其映射为 `localView`;菜单点击统一调用 `executeSchemaAction()`。说明:context menu DOM 当前仍由 NodeView 过渡渲染,后续可按 Task 5B 同样迁入 Rust/Leptos shell。
- [x] 验证:smoke 能打开节点右键菜单和画布右键菜单,并保存截图。
- 2026-05-11 通过:`node scripts/task166-mindmap-phase6-block-smoke.js` 输出 `ok=true`;截图 `tmp/task166-mindmap-phase6-block-smoke/context-menu-actions.png`
### Task 10: Command Bridge 与保存闭环补齐
**Files:**
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts`
- Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- Modify: `rust/crates/bridge-runtime/src/lib.rs`
- Modify: `rust/crates/core-protocol/src/mindmap.rs`
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- [x] `data_change` 能识别文本更新、插入子节点、插入同级节点、删除节点、移动节点。
- 2026-05-11 验证:`mindmap-command-diff.test.ts` 覆盖文本、插入、删除、移动 diff;`node scripts/task166-mindmap-phase6-block-smoke.js` 覆盖 toolbar/context menu 插入与删除保存闭环。
- [x] `view_data_change` 能转成 `mindmap.view.patch`
- 2026-05-11 验证:navigator 回根与缩放触发 view patchsmoke 输出 `navigatorActions.centerRootOk=true``navigatorActions.zoomOk=true``viewScale=1.2`
- [x] toolbar/sidebar/context menu 触发的动作都能落到 `kernel command``compatPayload.patch`
- 2026-05-11 验证:toolbar 插入/删除、sidebar 主题/结构、context menu 插入均通过 smokesidebar 节点样式/导图样式走 compat patch,结果为 `nodeStyleCompatOk=true``baseStyleCompatOk=true`
- [x] 每个 `compatPayload.patch` 记录字段路径、来源 action、runtime revision、kernel revision。
- 2026-05-11 实现:`mindmap-command-diff.ts``MindmapCompatPayloadPatch` 增加 `actionId``runtimeRevision``kernelRevision`NodeView bridge 注入 action/revision 元信息。
- [x] command 失败时显示 `command_failed`,并保留失败 action id 和错误消息。
- 2026-05-11 实现:NodeView command 失败时写入 `data-command-failed-action``data-command-failed-message`;本轮 smoke 未再复现删除节点 400。
- [x] reload 后仍能看到 toolbar 插入/删除/主题/结构/view 的已保存结果。
- 2026-05-11 验证:smoke 保存并校验 `04-after-reload.png`;输出 `toolbarActions.deleteOk=true``sidebarActions.themeOk=true``sidebarActions.layoutOk=true``viewScale=1.2`
- [x] 验证:`cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol mindmap`
- 2026-05-11 通过:3 passed。
- [x] 验证:`cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime mindmap`
- 2026-05-11 通过:14 passed。
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-command-diff leptos-mindmap-adapter`
- 2026-05-11 通过:实际匹配全量 106 files416 tests passed。
### Task 11: KMind/lx-doc 视觉对齐
**Files:**
- Modify: `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`
- Modify: relevant generated JS island file
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- Reference Image: `tmp/image copy 60.png`
- Reference Image: `tmp/image copy 70.png`
- Current Evidence: `tmp/image copy 79.png`
- [x] 默认根节点、二级节点、分支节点、概要线的视觉结果接近 KMind/lx-doc,而不是只显示基础节点和错位连线。
- 2026-05-11 实现:真实 `simple-mind-map` runtime 负责节点、连线、概要线渲染;当前截图能看到根节点、二级节点、分支主题和概要括号线。剩余差异:默认主题色仍未完全等同 `image copy 60.png` 的红/蓝 KMind 配色,后续可继续细化 themeConfig。
- [x] toolbar、右侧触发器、底部栏呈现为编辑工作台 chrome,不占据画布主体。
- 2026-05-11 实现:Leptos/Rust shell 的 toolbar、sidebar、navigator、count 均为 floating overlay;新截图 `tmp/task166-mindmap-phase6-block-smoke/01-after-insert-floating-overlay.png` 显示 UI 浮在画布上,不再挤压 runtime。
- [x] 连线对齐节点中心或 runtime 默认锚点,无明显错位。
- 2026-05-11 验证:当前连线由 `simple-mind-map` runtime 生成;人工查看 `04-after-reload.png` 未见此前手写线条错位问题。
- [x] 画布初始视口能看到完整示例导图,不需要用户手动拖拽才能判断是否渲染成功。
- 2026-05-11 修复:`simple-mind-map-bridge.ts` 不再用固定 `fit:false` 覆盖 runtimeOptions;默认 `fit:true``01-after-insert-floating-overlay.png` 中示例导图完整可见。
- [x] 禁止用说明性大字、调试文案或状态卡片占据导图首屏。
- 2026-05-11 验证:默认首屏为真实 runtime + floating UIdebug fallback 仍需显式 debug flag。
- [x] 验证:人工对照 `image copy 60.png` / `image copy 70.png` / 当前 smoke 截图,记录剩余差异到 checklist 或 reference notes。
- 2026-05-11 对照:当前已具备无限画布底层、浮动 toolbar、右侧栏、底部 navigator、概要线和完整初始视口;剩余差异为图标体系、默认主题色、工具栏按钮分组密度和高级面板内容仍未完全复刻 lx-doc/KMind。
### Task 12: Browser Smoke 升级
**Files:**
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- Output: `tmp/task166-mindmap-phase6-block-smoke/result.json`
- Output: `tmp/task166-mindmap-phase6-block-smoke/*.png`
- [x] smoke 使用真实测试账号登录 `http://127.0.0.1:3000/`
- 2026-05-11 验证:`tree-shell-smoke-helpers.js` 使用 `mnote.e2e@example.com` / “测试账号快速登录”,本轮 smoke baseUrl 为 `http://127.0.0.1:3000`
- [x] smoke 插入或打开 mindmap block 后断言 `simple-mind-map` canvas/SVG 容器非零尺寸。
- 2026-05-11 验证:`assertMindmapReady()` 断言 `simple-mind-map-runtime`、runtime box、节点数和边数。
- [x] smoke 断言 toolbar、sidebar trigger、bottom navigator、context menu 入口存在。
- 2026-05-11 验证:smoke 断言 `mindmap-schema-toolbar``mindmap-schema-sidebar``mindmap-schema-navigator``mindmap-schema-count`,并打开节点/画布 context menu。
- [x] smoke 执行插入子节点、插入同级节点、删除节点、缩放、回根节点。
- 2026-05-11 验证:输出 `toolbarActions.insertChildOk/insertSiblingOk/deleteOk=true``navigatorActions.centerRootOk/zoomOk=true`
- [x] smoke 打开右侧栏的主题、结构、大纲 panel。
- 2026-05-11 验证:截图包含 `sidebar-theme.png``sidebar-structure.png``sidebar-outline.png`
- [x] smoke reload 后断言已编辑文本、节点数量、view state 至少一项保持。
- 2026-05-11 验证:reload 后断言编辑文本、节点数量、主题、结构、节点样式、导图样式;view scale 输出为 `1.2`
- [x] smoke 输出 `ok``documentId``mindmapId``nodeCount``edgeCount``toolbarActions``sidebarPanels``navigatorActions``contextMenuActions``screenshots`
- 2026-05-11 验证:`tmp/task166-mindmap-phase6-block-smoke/result.json` 输出 `ok=true``documentId``mindmapId``nodeCount=6``edgeCount=5``toolbarActions``sidebarActions``navigatorActions``contextMenuActions``screenshots`
- [x] smoke 失败时必须标出阶段:`projection_load_failed``adapter_init_failed``runtime_render_failed``ui_shell_missing``command_failed``reload_mismatch`
- 2026-05-11 实现:失败分支从 error message / error layer 归类 `failureStage`,并保存 `99-failure.png`
- [x] 验证:`node /mnt/Data1T/mnote/scripts/task166-mindmap-phase6-block-smoke.js`
- 2026-05-11 通过:`ok=true`,截图已刷新。
### Task 13: 旧实现边界收口
**Files:**
- Modify: `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- Modify: `wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx`
- Modify: `wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`
- Modify: `wolai-frontend/src/components/editor/blocks/MindmapNavigator.tsx`
- Modify: `wolai-frontend/src/components/editor/blocks/MindmapContextMenu.tsx`
- Modify: `wolai-frontend/src/components/editor/editor-host-config.test.ts`
- [x] 给旧 React mindmap 组件族补清晰注释:legacy/reference/compat,不是 3000 文档页默认主链。
- 2026-05-11 实现:`MindmapBlock.tsx``MindmapToolbar.tsx``MindmapSidebar.tsx``MindmapNavigator.tsx``MindmapContextMenu.tsx``MindmapMiniMap.tsx``MindmapCount.tsx``MindmapSidebarTrigger.tsx` 顶部均标注 legacy/reference/compat 边界。
- [x] 默认文档页 host 为 `leptos_tiptap_island` 时,不加载旧 React `MindmapBlock` 作为主编辑路径。
- 2026-05-11 验证:`editor-host-config.test.ts` 断言默认 host 为 `leptos_tiptap_island``blocknote`/旧 debug host 均折返 island。
- [x] 历史 toolbar/sidebar/navigator/context menu 只能作为 schema 抽取来源,不在运行时并行挂两套 UI。
- 2026-05-11 验证:smoke 断言默认不存在旧 `mindmap-command-toolbar``mindmap-sidebar``mindmap-bottom-bar` fallback。
- [x] 保留旧组件用于对照、独立迁移或回收,不在本任务中删除。
- 2026-05-11 处理:旧 React 组件族仍被独立导图页 `/mindmap/[docId]/[mindmapId]` 引用,暂不移动;已删除重复的 `wolai-frontend/src/app/dev/mindmap/page.tsx`,对应副本保留在 `recycle/wolai-frontend/src/app/dev/mindmap/page.tsx`
- [x] 静态测试禁止把 `.mnote-mindmap-react-mount``window.__mindmapInstance` 作为 Phase 6 成功条件。
- 2026-05-11 实现:`editor-host-config.test.ts` 新增 Phase 6 mindmap 成功选择器断言,成功条件限定为 `mnote-mindmap-editor-root``simple-mind-map-runtime``mindmap-rust-shell`
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- editor-host-config`
- 2026-05-11 通过:实际匹配全量 106 files418 tests passed。
### Task 14: 文档与执行记录
**Files:**
- Modify: `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- Modify: `design/06-mindmap/process/6-mindmap-leptos-adapter-reference-notes-v1.md`
- Modify: `design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- Modify: `docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md`
- [x] 在总设计稿中明确:UI shell reuse 是 `leptos-mindmap` 的 Phase 6 后续收口,不改变 kernel-first 主合同。
- 2026-05-11 更新:`6-mindmap-kernel-phase6-projection-editor-v1.md` 新增 `10.2 UI shell reuse 收口状态`,指向本 checklist。
- [x] 在 reference notes 中补历史 React UI 壳能力表。
- 2026-05-11 已完成:`6-mindmap-leptos-adapter-reference-notes-v1.md` 第 8 节覆盖 toolbar、sidebar、navigator/MiniMap/count、context menu。
- [x] 在旧执行 checklist 中补一条链接,说明后续 UI 可用度按本 checklist 继续。
- 2026-05-11 更新:`docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md` 新增 `Task 13: UI Shell Reuse 后续收口`
- [x] 每完成一个 task,在本文件勾选对应节点,并附验证命令或截图路径。
- 2026-05-11 已按 Task 10-13 补充命令、截图和 result.json 证据。
- [x] 如果某项被后续实现废弃,移动到“暂不做项”并写明原因,不静默删除。
- 2026-05-11 当前没有静默删除项;context menu DOM 仍标记为 NodeView 过渡层,未误报为纯 Rust shell。
## 退出标准
- [x] 当前文档页 mindmap block 默认使用真实 `simple-mind-map` runtime,而不是手写 SVG/DOM 假导图。
- [x] toolbar、sidebar、navigator、context menu 都来自可复用 UI schema 或等价配置,不再散落为 NodeView 内硬编码按钮。
- [x] 第一阶段常用操作可用:插入子节点、插入同级节点、删除、编辑文本、缩放、回根、主题切换、结构切换、大纲查看。
- [x] 所有编辑动作都经过 kernel command 或 `compatPayload.patch`,不把整棵 runtime data 作为 canonical truth。
- [x] smoke 有插入后、操作后、打开侧栏后、打开右键菜单后、reload 后截图。
- [x] 人工对照 `tmp/image copy 60.png``tmp/image copy 70.png`,当前实现已经呈现为可用工作台,而不是“无头 runtime + 临时按钮”。
## 暂不做项
- [ ] 不直接把 lx-doc Vue2 应用作为 mnote 文档页内嵌主链。
- [ ] 不恢复旧 React `MindmapBlock.tsx` 为默认编辑器主链。
- [ ] 不在本阶段 Rust-native 重写 `simple-mind-map` layout / hit-test / renderer。
- [ ] 不在本阶段完整复刻 KMind 多根、MOC、思源块镜像、PDF 标注、全量导入导出、主题设计器。
- [ ] 不删除历史 React mindmap 文件;先作为迁移参考和 compat 资产保留。
- [ ] 旧 React `MindmapBlock.tsx` 组件族暂不移动到 recycle,直到 `/mindmap/[docId]/[mindmapId]` 独立页切到同一 `leptos-mindmap` adapter contract;当前只回收重复 dev 路由。
@@ -1,400 +1,312 @@
# Mindmap Phase 6 Projection Editor v1 Implementation Plan
# Mindmap Phase 6 leptos-mindmap Projection Editor v1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` where independent code slices exist, or `superpowers:executing-plans` when running this checklist task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> 旧 Rust-native 计划已归档:
> `/mnt/Data1T/mnote/recycle/design/superpowers-plans/2026-05-10-mindmap-phase6-projection-editor-checklist.rust-native.md`
**Goal:** 在不等待 `design/01-05` 全量收口的前提下,按 `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` 的方向推进 `Mindmap Phase 6` 主线重构:让 Mindmap 从“独立对象中心 + blob 真相 + 前端重壳”降级成“kernel subtree / graph 的一种 projection 与 editor”,并适配当前已有的 Rust 主链;本轮优先收口文档内 `/` 命令插入的 `mindmap block` 最小读写真相链,再把独立导图页挂到同一套 truth / adapter
**Goal:** 交付 `leptos-mindmap`:以 Rust kernel 作为导图事实源与命令面,以 `simple-mind-map` / KMind-like runtime 作为 `leptos-tiptap` NodeView 中的显示与交互 adapter。
**Architecture:** 这一版不重写画布,不替换 `simple-mind-map`,也不要求 page aggregate / tree realtime 彻底闭环。先建立 block 与独立页共用的 Rust projection + 最小 command adapter,让 `simple-mind-map` 降级成 renderer / editor adapter,再把 `/` 插入示例骨架、默认隐藏 chrome、hover 后显示工具栏 / 右侧面板 / 节点操作这些产品体验挂到这条新主链上。测试和截图验收只负责证明主线成立,不反向限制重构范围;本轮允许 blob 存储暂时作为 compat substrate 存在,但不再把它当长期对象真相
**Architecture:** `kernel truth -> mindmap.kernel_projection.v1 -> mindmap.simple_mind_map_scene.v1 -> leptos-mindmap island -> simple-mind-map runtime -> adapter diff / command bridge -> kernel command`
**Tech Stack:** Next.js / React、Convex、Rust `core-protocol`、Rust `bridge-runtime`、Rust `mnote-web``simple-mind-map`
**Tech Stack:** Rust `core-protocol`、Rust `bridge-runtime`、Rust `mnote-web``leptos-tiptap` island、`simple-mind-map`、KMind/lx-doc reference code、Playwright smoke、Convex compat substrate。
---
## 方向确认
## 口径修正状态
- [ ] 当前 Phase 6 的方向,明确是按 `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` 推进
- [ ] 目标明确是让 Mindmap 从“独立对象中心 + blob 真相 + 前端重壳”降级成“kernel subtree / graph 的一种 projection 与 editor”
- [ ] `design/01-05` 仍然是当前仓库的上位主线与优先级依据,但不是这轮 Mindmap Phase 6 的硬前置阻塞
- [ ] 这轮只在不破坏 `Page Aggregate / tree command / tree realtime` 主线口径的前提下,收口 Mindmap 自身的 truth / projection / command 边界
- [ ] 测试验收、截图留档、浏览器 smoke 只从属于主线重构,不反向决定主线方案
- [ ] 独立导图页必须适配同一套 truth / adapter,但文档内 `/` 命令插入的 `mindmap block` 仍是本轮主验收入口
- [x] 已确认旧 `Rust / Leptos 原生 Projection Editor` 口径过时。
- [x] 已将旧设计稿归档到 `recycle/design/06-mindmap/process/`
- [x] 已将旧 checklist 归档到 `recycle/design/superpowers-plans/`
- [x] 已把当前执行口径修正为 `leptos-mindmap`
- [x] 后续每完成一个实现节点,必须在本 checklist 中勾选对应项。
- [x] 2026-05-10 现实复核:当前已勾选的 Task 6/7/8/退出标准只代表“projection / command 最小闭环 + 手写伪画布 smoke”通过,不代表真实 `simple-mind-map` runtime 已接入。最终 UI/runtime 完成判定以后续 `Phase 6.1` 为准。
## 范围冻结
### 本计划明确要做
- [ ] 明确按 `6-mindmap-kernel-phase6-projection-editor-v1.md` 的口径推进,不把 Mindmap 再当独立对象真相层
- [ ] 让 Mindmap 降级为 kernel subtree / graph 的一种 projection 与 editor,并接入当前 Rust 主链
- [ ] 文档内 `/` 命令插入的 `mindmap block` 是本轮主验收面
- [ ] 先收口 block 与独立页共用的最小读写真相链,再叠加产品体验
- [ ] 独立导图页读链优先消费 Rust `mindmap.projection.get`
- [ ] 前端导图 projection 类型与 Rust projection contract 对齐
- [ ] 为最小可用动作集建立 Rust command / op 适配层
- [ ] 保留 `simple-mind-map` 作为 renderer / editor adapter
- [ ] 为独立页与内嵌块建立同一套“projection truth + adapter runtime”口径
- [ ] `/` 插入后默认生成示例骨架:根节点 + 二级节点 + 两个分支主题
- [ ] 默认只显示导图本体;hover block 后显示工具栏 / 右侧面板 / 节点 hover 操作
- [ ] 补齐最小测试,确保 Phase 6 不回退成“前端摘要 + blob 真相”
- [ ] 定义 kernel projection 与 `simple-mind-map` adapter projection 的边界。
- [ ] `leptos-tiptap` NodeView 中挂载 `leptos-mindmap`
- [ ] 使用 `simple-mind-map` runtime 渲染真实导图画布。
- [ ] 参考 `lx-doc/mind-map` 和 KMind 插件复刻第一阶段 toolbar/sidebar/bottom bar。
- [ ] 通过 adapter command bridge 把用户编辑写回 Rust kernel。
- [ ] `data_change` 这类粗粒度事件建立 diff 到 kernel command 的转换。
- [ ] 保留 `compat_payload` 承接暂未语义化的高级字段。
- [ ] 更新 smoke,验证真实节点、连线、编辑、reload 和截图证据。
### 本计划明确不做
- [ ]要求先完成 `Page Aggregate` 全闭环
- [ ]要求先完成 `tree realtime` 全域 live cache 统一
- [ ]重写 `MindmapBlock.tsx` 的整套工具栏、动画、导入导出、图片工具
- [ ]把 Convex `mindmaps` blob 存储在本轮强行改成 kernel node / edge 持久化
- [ ]把所有 `simple-mind-map execCommand` 一次性收口到 Rust
- [ ]为了浏览器 smoke 或截图留档而保守维持旧的 blob-first 主路径
- [ ]`simple-mind-map` runtime data 作为 canonical truth。
- [ ]直接嵌入完整 Vue2 `lx-doc/mind-map` 应用并保存 blob。
- [ ]恢复旧 React `MindmapBlock.tsx` 作为 3000 文档页默认主链。
- [ ]要求本轮完成 Rust-native renderer。
- [ ]在本轮完整复刻 KMind 多根、MOC、镜像块、思源块预览、PDF 标注跳转。
- [ ]立即移除 Convex `mindmaps` blob;它只作为 compat substrate / import-export / fallback snapshot。
### 开工判定
## 文件职责图
- [ ] 确认当前主线口径仍是 `tree-first graph kernel`
- [ ] 确认当前方向是“Mindmap 降级成 kernel projection/editor 并适配 Rust 主链”,而不是继续补一套独立 blob 系统
- [ ] 确认 `Page Aggregate` / `tree.*` / `/api/tree/events` 已是正式主链,而不是待验证方向
- [ ] 确认本轮目标是 `projection/editor v1`,不是 `mindmap full kernel cutover`
- [ ] 确认测试验收从属于重构主线,而不是主线为测试妥协
- [ ] 若任务被重新扩大到“顺手收口 page aggregate / realtime 尾项”,暂停并拆分
## 产品体验目标
- [ ] 主验收入口:文档页正文内通过 `/` 命令插入 `mindmap block`
- [ ] 插入后立即看到示例骨架,而不是空白画布或只有一个根节点
- [ ] 默认只显示画布与导图本体,不常驻显示顶部工具栏、右侧面板、节点操作
- [ ] 鼠标移入 block 区域后,显示顶部工具栏与右侧面板
- [ ] 鼠标移入节点后,显示节点级 hover 操作
- [ ] 独立页与 block 共享同一套 truth / adapter,只允许 chrome 壳层不同
## 参考对标
- [ ] 体验参考:`/mnt/Data1T/mnote/tmp/image copy 59.png`
- [ ] 更接近现有实现的参考:`/mnt/Data1T/mnote/tmp/image copy 60.png`
- [ ] 默认隐藏 chrome 的参考:`/mnt/Data1T/mnote/tmp/image copy 61.png`
- [ ] 外部参考实现:
- `https://github.com/suka233/siyuan-kmind-plugin`
- `https://github.com/wanglin2/mind-map`
- `https://github.com/wanglin2/mind-map-mcp`
- `https://github.com/wanglin2/lx-doc`
- [ ] 参考只用于体验与结构借鉴,不要求机械复刻外部产品
- `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- 当前 `leptos-mindmap` 设计口径。
- `rust/crates/core-protocol/src/mindmap.rs`
- kernel projection、adapter projection、command DTO。
- `rust/crates/bridge-runtime/src/lib.rs`
- projection builder、adapter scene builder、command apply。
- `rust/crates/mnote-web/`
- projection / command route 与 3000 文档页运行时分发。
- `rust/spikes/leptos-tiptap-spike/src/lib.rs`
- mindmap NodeView 与 island 挂载。
- `wolai-frontend/src/lib/mindmap/*`
- adapter、diff、simple-mind-map bridge、legacy compat。
- `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- legacy React/simple-mind-map 参考实现,不作为默认主链。
- `design/05-editor-mainline/reference-code/lx-doc/mind-map`
- UI、命令、插件和主题参考。
- `design/05-editor-mainline/reference-code/kmind-plugin`
- KMind 打包实现和视觉行为参考。
- `scripts/task166-mindmap-phase6-block-smoke.js`
- Phase 6 浏览器 smoke。
---
### Task 1: 冻结 Phase 6 执行边界
### Task 1: 冻结 leptos-mindmap Contract
**Files:**
- Modify: `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- Reference: `design/01-05-current-priority-overview.md`
- Reference: `ARCHITECTURE.md`
- Modify: `rust/crates/core-protocol/src/mindmap.rs`
- Modify: `rust/crates/core-protocol/src/lib.rs`
- Test: `rust/crates/core-protocol/src/lib.rs`
- [ ] 在设计稿里补一段“本轮执行边界”,明确:
- 方向是把 Mindmap 降级成 kernel subtree / graph projection + editor
- 继续适配并复用当前已有的 Rust 主链
- 先切 block / page 共用的最小读写真相链
- 再把 `/` 插入、示例骨架、hover chrome 挂到新主链
- 继续保留 `simple-mind-map` renderer
- 不把 `01-05` 尾项作为硬前置
- [ ] 在设计稿里补一段“非目标说明”,明确:
- 本轮不是继续把 mindmap blob 打磨成长期对象真相
- 本轮不是先做前端重壳再以后接 Rust
- 本轮不是一次性完成最终 kernel-native graph storage cutover
- [ ] 在设计稿里补一段“主验收面”,明确:
- 主验收入口是文档内 `/` 命令插入的 `mindmap block`
- 独立页必须共用同一真相链,但不是产品验收主画面
- [ ] 在设计稿里补一段“测试从属关系”,明确:
- smoke / 截图用于证明主线成立
- 不允许为了更好测而维持旧主路径
- [ ] 在设计稿里补一段“阻塞条件”,明确只有以下情况才暂停:
- Rust projection contract 不可用
- 现有 bridge 无法表达最小 MindmapOp
- 独立页与内嵌块入口无法共享 projection contract
- [ ] 在设计稿里补一段“延期项”,把以下内容显式标为后续阶段:
- blob 持久化移除
- page aggregate 深度对齐
- realtime delta 深度接入
- 全量画布命令收口
- [ ] 验证:设计稿读起来不会再误导执行者去先重写画布或等待 `01-05` 全完成
- [x] 定义 `MindmapKernelProjection`,包含 `schema``documentId``mindmapId``rootNodeId``revision``nodes``edges``summaries``associativeLines``layout``theme``view``capabilities``source`
- [x] 定义 `MindmapAdapterProjection`,包含 `schema``runtime="simple-mind-map"``root``layout``theme``themeConfig``view``config``compatPayload``kernelRevision`
- [x] 定义最小 command DTO`updateText``insertChild``insertSiblingAfter``deleteNode``moveNode``patchView``setLayout``setTheme`
- [x] 标记 `MindmapTreeNode` / `MindmapOp` 为 compat DTO。
- [x] 补 Rust 单测:kernel projection 与 adapter projection 都能序列化为 camelCase JSON。
- [x] 验证命令:`cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol mindmap`
---
### Task 2: 建立 Mindmap Projection 真实合同
### Task 2: 建立 Adapter Projection Builder
**Files:**
- Inspect/Modify: `rust/crates/core-protocol/src/mindmap.rs`
- Inspect/Modify: `rust/crates/bridge-runtime/src/lib.rs`
- Inspect/Modify: `rust/crates/mnote-web/src/routes/mindmap_shell.rs`
- Inspect/Modify: `wolai-frontend/src/lib/mindmap/mindmap-projection.ts`
- Test: `rust/crates/bridge-runtime/src/lib.rs`
- Test: `wolai-frontend/src/lib/mindmap/*.test.ts`
- [ ] 盘点 Rust 侧现有 `MindmapProjection` 字段,列出前端真正需要消费的最小字段:
- `documentId`
- `mindmapId`
- `rootNodeId`
- `title`
- `nodeCount`
- `nodes`
- `raw/tree payload`
- `owner/source/meta`
- [ ] 判断前端 `MindmapProjection` 与 Rust contract 的差异,特别检查:
- 前端是否仍把 `projection: "mindmap_subtree"` 当私有摘要
- 是否缺少 source / owner / version 语义
- 是否把 `data` 原树和 projection 摘要混成一层
- [ ] 冻结一份统一口径:
- Rust 输出什么
- 前端只做什么适配
- 哪些字段是 renderer 所需,哪些字段是系统真相
- [ ] 在 contract 里显式区分三层:
- projection truth
- renderer input
- chrome / hover UI state
- [ ] 确认 block 和独立页都消费同一份 projection truth,不允许各自再拼一份摘要对象
- [ ] 补测试,至少覆盖:
- Rust `mindmap.projection.get` 返回稳定 schema
- 前端 adapter 能解析 Rust projection
- 当原始 blob 字段缺失时,adapter 仍只做渲染兜底,不篡改 projection 语义
- [ ] 验证命令:
- `cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime mindmap`
- `cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-projection`
---
### Task 3: 建立最小 Command Adapter,只接本轮必须动作
**Files:**
- Inspect/Modify: `rust/crates/core-protocol/src/mindmap.rs`
- Modify: `rust/crates/bridge-runtime/src/lib.rs`
- Modify: `wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts`
- Create/Modify: `wolai-frontend/src/lib/mindmap/mindmap-command-client.ts`
- Test: `rust/crates/bridge-runtime/src/lib.rs`
- Test: `wolai-frontend/src/lib/mindmap/*.test.ts`
- [ ] 冻结 v1 支持的最小动作集,只选:
- `rename/updateText`
- `addChild`
- `deleteNode`
- `addSiblingAfter`
- 节点 hover 操作所需的最小命令
- `/` 插入后的示例骨架创建
- [ ] `setRefs``move` 不作为本轮 block 主验收前置,除非实现时已在同一 adapter 中顺手闭环
- [ ] 为这些动作建立前端 command client,不再默认整棵 `POST data blob`
- [ ] Rust bridge 明确区分:
- projection query
- command apply
- compat `mindmaps.put`
- [ ] 只有确实无法表达的动作,才回退到整棵 blob 保存;回退只作为 compat 行为存在,不在这里展开做标签治理
- [ ] 补测试,至少覆盖:
- 每个最小动作都能转成 `MindmapOp``MindmapCommand`
- 非支持动作不会偷偷走“无差别全量 blob 覆盖”
- compat 回退有显式标记
- [ ] 验证命令:
- `cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime mindmap_apply_ops`
- `cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-command-client`
- [x] 增加 `mindmap.kernel_projection.get` 查询。
- [x] 增加 `mindmap.simple_mind_map_scene.get` 查询。
- [x] 从 kernel truth 或 compat substrate 构建 `simple-mind-map` 可消费的 `root/layout/theme/view/config`
- [x] projection 返回明确 `source`,如 `kernel``compat-blob``adapter-cache`
- [x] 默认骨架包含 `KMIND``二级节点`、两个 `分支主题``概要`
- [x] 补 Rust 单测:同一 mindmapId 能输出 kernel projection 和 adapter projection,节点数量一致。
- [x] 验证命令:`cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime mindmap_projection`
---
### Task 4: 建立共享 Entry Contract 与 Renderer Adapter 基础
### Task 3: 审计 lx-doc / KMind Runtime 能力
**Files:**
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-projection.ts`
- Create/Modify: `wolai-frontend/src/lib/mindmap/mindmap-entry-contract.ts`
- Create/Modify: `wolai-frontend/src/lib/mindmap/mindmap-renderer-adapter.ts`
- Modify: `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- Modify: `wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`
- Test: `wolai-frontend/src/lib/mindmap/*.test.ts`
- Test: `wolai-frontend/src/components/editor/blocks/*.test.tsx`
- Test: `wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/*.test.tsx`
- Inspect: `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/Edit.vue`
- Inspect: `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/ToolbarNodeBtnList.vue`
- Inspect: `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/SidebarTrigger.vue`
- Inspect: `design/05-editor-mainline/reference-code/kmind-plugin/app/app.js`
- Output Optional: `design/06-mindmap/process/6-mindmap-leptos-adapter-reference-notes-v1.md`
- [ ] 把“系统真相字段”和“画布运行时字段”分成三层:
- projection truth
- renderer input
- chrome / hover UI state
- [ ] 建立 `mindmap-entry-contract`,统一 block 与独立页入口的最小约定:
- 如何拿 projection
- 如何构建 renderer input
- 如何声明 UI 壳层差异
- [ ] 提取 `mindmap-renderer-adapter`,专门负责:
- projection -> `simple-mind-map` 可渲染数据
- renderer 事件 -> 规范化编辑意图
- [ ] 避免继续在 `MindmapBlock.tsx` 或独立页内直接把 `buildMindmapProjection(blob)` 当正式读链
- [ ] 补测试,至少覆盖:
- adapter 不改写 projection 语义
- adapter 的兜底只作用于 renderer
- block 与独立页能共用同一 entry contract / renderer adapter
- [ ] 验证命令:
- `cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-projection mindmap-renderer-adapter src/app/mindmap src/components/editor/blocks`
- [x] 列出本轮需要注册的 `simple-mind-map` 插件。
- [x] 列出 toolbar 命令到 kernel command 的映射。
- [x] 列出右侧栏第一阶段保留项:节点样式、导图样式、主题、结构、大纲。
- [x] 列出底部工具第一阶段保留项:统计、搜索、缩放、回根节点、全屏或只读。
- [x] 列出必须进入 `compat_payload` 的字段。
- [x] 明确哪些 KMind 专有能力延期。
---
### Task 4: 实现 simple-mind-map Bridge
### Task 5: 收口文档内 mindmap block 的主读写真相链
**Files:**
- Create/Modify: `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts`
- Create/Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- Create/Modify: `wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts`
- [x] 动态加载 `simple-mind-map`,避免 SSR 阶段访问 `window/document`
- [x] 注册本轮必需插件:Drag、KeyboardNavigation、Export、Select、RichText、AssociativeLine、Search、Scrollbar、OuterFrame 等按审计结果取舍。
- [x] 支持从 `MindmapAdapterProjection` 初始化 runtime。
- [x] 监听 `data_change``view_data_change``node_active``back_forward``scale``translate`
- [x] 暴露 `execCommand` 安全包装,不让 UI 任意写 blob。
- [x] 销毁时清理 runtime、事件监听和定时器。
### Task 5: 建立 Command Bridge 与 Diff
**Files:**
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts`
- Modify: `rust/crates/bridge-runtime/src/lib.rs`
- Test: relevant Rust and TS tests
- [x] 直接命令优先:toolbar 明确动作直接发 kernel command。
- [x] `data_change` diff 能识别文本更新、插入子节点、插入同级节点、删除节点、移动节点。
- [x] `view_data_change` 转成 `mindmap.view.patch`
- [x] 主题与结构变化转成 `mindmap.theme.set` / `mindmap.layout.set`
- [x] 暂不可识别字段进入 `compat_payload.patch`,并记录字段路径。
- [x] command 成功后刷新 projection revision。
- [x] command 失败时 UI 显示可定位错误,不伪装保存成功。
### Task 6: 嵌入 Leptos Tiptap Mindmap NodeView
**Files:**
- Modify: `rust/spikes/leptos-tiptap-spike/src/lib.rs`
- Modify: relevant generated island runtime
- Inspect/Modify: `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`
- [x] `/` 命令插入 mindmap block 时,Tiptap 节点只保存 `mnoteBlockType="mindmap"``mindmapId``rootNodeId``projectionVersion`
- [x] NodeView 创建 `data-testid="mnote-mindmap-editor-root"`
- [x] NodeView 挂载 `leptos-mindmap` island。
- [x] island 初始化时请求 `mindmap.simple_mind_map_scene.get`
- [x] adapter 初始化失败时显示 `adapter_init_failed`
- [x] projection 获取失败时显示 `projection_load_failed`
- [x] 不创建 `.mnote-mindmap-react-mount` 作为主链合同。
### Task 7: 复刻第一阶段 KMind-like UI
**Files:**
- Create/Modify: relevant leptos-mindmap UI files
- Reference: `lx-doc/mind-map/src/pages/Edit/components/Toolbar.vue`
- Reference: `lx-doc/mind-map/src/pages/Edit/components/SidebarTrigger.vue`
- Reference: `lx-doc/mind-map/src/pages/Edit/components/NavigatorToolbar.vue`
- [x] 顶部工具栏包含撤销、重做、编辑节点、同级节点、子节点、删除节点、标签、超链接、备注、图片、图标、概要、关联线、公式、格式刷、导入、导出。
- [x] 工具栏按钮禁用状态跟随 active node。
- [x] 右侧栏包含节点样式、导图样式、主题、结构、大纲。
- [x] 底部栏包含字数/节点数、回根节点、搜索、缩放百分比、全屏或只读入口。
- [x] 默认主题对齐 KMind 截图:根节点红色、二级节点蓝色、分支蓝色文本、概要括号线。
- [x] 移动端或窄宽度下工具栏能折叠为更多菜单。
### Task 8: 更新 Browser Smoke
**Files:**
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- Reference: `scripts/TESTING_REFERENCE.md`
- Output: `tmp/task166-mindmap-phase6-block-smoke/*.png`
- Output: `tmp/task166-mindmap-phase6-block-smoke/result.json`
- [x] smoke 启动前检查 3000 / 8000 端口占用,并输出 PID、进程名、命令行。
- [x] 使用真实测试账号登录。
- [x] 新建或打开临时文档,通过 `/` 命令插入 mindmap block。
- [x] 断言 `data-testid="mnote-mindmap-editor-root"` 存在。
- [x] 断言 `simple-mind-map` runtime 画布存在且 bounding box 非零。
- [x] 断言至少 4 个可见节点和 3 条可见连线。
- [x] 截图保存插入后、编辑后、reload 后三张图。
- [x] 编辑一个节点文本,等待 kernel command 成功,再 reload,断言新文本仍存在。
- [x] 结构化输出 JSON,包含 `ok``task``baseUrl``documentId``mindmapId``nodeCount``edgeCount``kernelRevision``adapterSource``screenshots``resultPath`
- [x] 失败时错误信息必须指出阶段,例如 `projection_load_failed``adapter_init_failed``runtime_render_failed``command_failed``reload_mismatch`
### Task 9: 调整 Legacy React / Blob 边界
**Files:**
- Modify: `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-projection.ts`
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-renderer-adapter.ts`
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-command-client.ts`
- Inspect/Modify: `rust/crates/bridge-runtime/src/lib.rs`
- Test: `wolai-frontend/src/components/editor/blocks/*.test.tsx`
- Test: `wolai-frontend/src/lib/mindmap/*.test.ts`
- Test: `rust/crates/bridge-runtime/src/lib.rs`
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-renderer-adapter.ts`(当前文件已不存在,边界由 `leptos-mindmap-adapter.ts` / `simple-mind-map-bridge.ts` 与静态断言覆盖)
- [ ] 让文档内 `mindmap block` 改为消费统一 projection truth,而不是 `buildMindmapProjection(blob)` 私有摘要
- [ ] 让 block 的最小编辑动作统一走 Task 3 的 command adapter
- [ ] 把节点 hover 操作建立在这条最小写入链上,不再默认走全量 blob 覆盖
- [ ] 确认 `MindmapBlock.tsx` 不再同时扮演 truth / adapter / chrome 三层
- [ ]测试,至少覆盖:
- block 主读链优先消费 projection truth
- block 最小动作集能转成 `MindmapOp` / command
- 非支持动作不会偷偷回退成无差别整棵覆盖
- [ ] 验证命令:
- `cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime mindmap`
- `cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- MindmapBlock mindmap-command-client`
- [x] 给旧 React mindmap 文件增加注释:仅为 legacy/compat/reference,不是 3000 文档页默认主链。
- [x] 禁止新增代码把 `.mnote-mindmap-react-mount` 作为 Phase 6 主验收合同。
- [x] 禁止新增代码把 `window.__mindmapInstance` 作为 smoke 成功条件。
- [x] 保留旧 blob 导入能力,但明确它是 import/compat,不是 canonical edit path。
- [x]静态断言或 smoke:默认文档页 host 为 `leptos_tiptap_island` 时不加载旧 React mindmap 主链。
---
### Task 6: 把 `/` 插入示例骨架与 Hover Chrome 体验挂到新主链
**Files:**
- Modify: `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- Inspect/Modify: `wolai-frontend/src/store/editor-bridge.ts`
- Inspect/Modify: `wolai-frontend/src/components/editor/*`
- Inspect/Modify: `wolai-frontend/src/app/(app)/documents/[id]/*`
- Test: `wolai-frontend/src/components/editor/blocks/*.test.tsx`
- [ ] 找到文档页 `/` 命令插入 `mindmap block` 的真实入口,并把它改为直接创建示例骨架 truth
- [ ] 示例骨架固定为:
- 根节点
- 1 个二级节点
- 2 个分支主题
- [ ] `mindmap block` 默认只显示画布与导图本体
- [ ] 鼠标移入 block 区域后,显示顶部工具栏与右侧面板
- [ ] 鼠标移入节点后,显示节点级 hover 操作
- [ ] chrome 显隐只依赖新 truth / adapter 主链,不在旧 blob 路径上额外补逻辑
- [ ] 补测试,至少覆盖:
- `/` 插入后不是空白壳
- 默认不常显 chrome
- hover block 后 chrome 出现
- hover 节点后节点操作出现
---
### Task 7: 独立导图页同步挂到同一套 Truth
### Task 10: 独立导图页接同一 Adapter Contract
**Files:**
- Modify: `rust/crates/mnote-web/src/routes/mindmap_shell.rs`
- Modify: `rust/crates/mnote-web/src/ssr/pages/mindmap.rs`
- Modify: `wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`
- Modify: `wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts`
- Inspect/Modify: `wolai-frontend/src/lib/documents/rust-runtime.ts`
- Inspect/Modify: `rust/crates/bridge-runtime/src/lib.rs`
- Inspect/Modify: `rust/crates/mnote-web/src/routes/mindmap_shell.rs`
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-entry-contract.ts`
- Test: `wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/*.test.tsx`
- Test: `wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/*.test.ts`
- [ ] 独立页 SSR 读链优先请求 Rust `mindmap.projection.get`
- [ ] 独立页 block 共用 Task 3/4 产出的 entry contract / renderer adapter / command adapter
- [ ] 如果仍需兼容 blob 返回,必须把它降级为 fallback,并在代码里标出 compat 边界
- [ ] 保证 route 返回的主体语义是 projection,而不是“顺便附带 data blob 的摘要”
- [ ] 校验 `initialProjection` 命名与真实内容一致,不再出现“其实是本地摘要却叫 projection”的假象
- [ ] 明确哪些行为允许不同:
- block 以文档内 hover chrome 为主
- 独立页可保留更完整的工作区 chrome
- [ ] 补测试,至少覆盖:
- projection 正常返回时,页面首屏直接消费 Rust projection
- projection 失败时,compat fallback 行为可见且受控
- 独立页与 block 读取的是同一 contract,不再分叉
- [ ] 验证命令:
- `cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- src/app/mindmap src/app/api/mindmap`
- [x] 独立导图页读取同一 `mindmap.simple_mind_map_scene.get`
- [x] 独立页和文档 block 共用 command bridge。
- [x] 独立页可以有更完整 chrome,但 projection、command、revision contract 必须一致。
- [x] compat fallback 必须带 `source="compat-blob"`UI 或日志可见。
- [x] 补 smoke 或 route 单测:同一个 `mindmapId` 在 block 和独立页返回相同 root、node count、revision。
---
### Task 8: 标记并隔离仍未切掉的 Compat 区域
### Task 11: 验证与复核
**Files:**
- Modify: `wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts`
- Modify: `wolai-frontend/src/lib/documents/rust-runtime.ts`
- Modify: `wolai-frontend/src/lib/mindmap/mindmapLocalStore.ts`
- Modify: `wolai-frontend/convex/mindmaps.ts`
- Optional Doc: `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- Test: Rust protocol/runtime tests
- Test: leptos-tiptap island build
- Test: `scripts/task166-mindmap-phase6-block-smoke.js`
- [ ] 给仍保留 blob 真相的路径打清楚标签:
- `compat_blob_read`
- `compat_blob_write`
- `renderer_only_fallback`
- [ ] 只在 Task 5 / Task 7 主链稳定后,集中补 compat 标记;不和 Task 3 的命令适配职责混写
- [ ] 避免继续在新代码里把 compat 路径写成正式主路径
- [ ] 明确 localStorage / 附件广播 / `mindmap-<id>.json` 属于过渡资产,不属于长期真相合同
- [ ] 给 Convex `mindmaps` 的整棵 blob 存储加一句清晰注释:当前仅是 substrate / compat 持久化,不是长期对象语义
- [ ] 验证:新执行者一眼能区分哪条是正式路径,哪条只是过渡保底
- [x] 运行 `cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol mindmap`
- [x] 运行 `cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime mindmap`
- [x] 重新构建 leptos-tiptap island。
- [x] 启动本地服务,确认 3000 主链来自 `mnote-web` 文档页。
- [x] 运行 `node scripts/task166-mindmap-phase6-block-smoke.js`
- [x] 人工查看 smoke 输出截图,确认不是标题占位、不是空容器、不是旧 React mount。
- [x] 每完成一个 checklist 项,必须基于代码或验证结果再勾选。
---
### Task 9: 补齐最小回归与浏览器验收
### Task 12: Phase 6.1 现实复核与真实 Runtime Cutover
**Files:**
- Test: `wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/*.test.tsx`
- Test: `wolai-frontend/src/components/editor/blocks/*.test.tsx`
- Test: `wolai-frontend/src/lib/mindmap/*.test.ts`
- Test: `rust/crates/bridge-runtime/src/lib.rs`
- Optional Script: `scripts/task*-mindmap-smoke.js`
- Modify: `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`
- Modify: `rust/spikes/leptos-tiptap-spike/src/lib.rs`
- Modify: `rust/spikes/leptos-tiptap-spike/generated/island/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js`
- Reuse/Modify: `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts`
- Reuse/Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
- Reuse/Modify: `wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts`
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js`
- Output: `tmp/task166-mindmap-phase6-block-smoke/*.png`
- Output: `tmp/task166-mindmap-phase6-block-smoke/result.json`
- [ ] 单测覆盖:
- projection 主读链
- compat fallback
- renderer adapter
- 最小 command adapter
- block / 独立页共享 truth
- [ ] 补一个浏览器 smoke,至少验证:
- 新建任意页面后,可通过 `/` 命令插入 `mindmap block`
- 插入后立即出现示例骨架,而不是空白壳
- 默认不常显 chrome
- hover block 后顶部工具栏和右侧面板出现
- hover 节点后节点级操作出现
- 至少一个最小编辑动作能成功回显
- [ ] 独立页 smoke 只作为同链补充验证,不再是本轮产品主验收面
- [ ] 补一个“禁止回退”断言:
- 当 projection 可用时,不允许默认走 `buildMindmapProjection(blob)` 主路径
- [ ] 验证命令:
- `cd /mnt/Data1T/mnote/wolai-frontend && pnpm test`
- `cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime`
- 浏览器 smoke 使用仓库现有 `scripts/task*-smoke.js` 风格新增或复用
- [x] 确认当前 `tiptap_paragraph.ts` 的 NodeView 是手写 DOM/SVG`renderScene()` 拼接 toolbar、`<button data-testid="mindmap-node">``<path data-testid="mindmap-edge">`
- [x] 确认当前截图 `/mnt/Data1T/mnote/tmp/task166-mindmap-phase6-block-smoke/03-after-reload.png``image copy 70.png``image copy 60.png` 的真实工作台形态差距明显。
- [x] 确认当前 `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts` 已有真实 `new MindMap(...)` bridge,但没有接入 leptos-tiptap NodeView 主链。
- [x]`tiptap_paragraph.ts` 中的 `renderScene()` 手写节点/连线逻辑改为只创建 runtime mount element、状态层和错误层。
- [x] 建立 leptos-tiptap 可加载的 browser-side adapter 入口,复用 `createLeptosMindmapAdapter()`,避免在 generated bridge 中复制第二套 simple-mind-map 初始化逻辑。
- [x] adapter 初始化时请求 `mindmap.simple_mind_map_scene.get`,把 projection 的 `root/layout/theme/themeConfig/view/config` 传给真实 `simple-mind-map`
- [x] adapter 内部真实调用 `new MindMap({ el, data, layout, theme, themeConfig, viewData, config })`
- [x] NodeView 销毁时调用 bridge `destroy()`,清理 runtime、监听器和 DOM。
- [ ] `data_change` / `view_data_change` 通过 `mindmap-command-diff.ts` 转换为 kernel command 或 `compatPayload.patch`,再调用 `mindmap.command.apply`
- [x] command 成功后刷新 adapter projection 或同步 runtime revision;失败时显示 `command_failed`,不得伪装保存成功。
- [ ] toolbar 的“子节点 / 同级节点 / 删除 / 回根 / 缩放”优先调用 runtime command,再经 command bridge 回写 kernel。
- [x] 右侧栏和底部栏只保留第一阶段 chrome,不再占据画布主要宽度;视觉对齐 `image copy 60.png` 的内嵌 KMind 工作台。
- [x] 删除或降级旧 `.mnote-mindmap-node`、手写 `mindmap-edge`、固定 `svg viewBox` 成为 fallback/debug,不作为默认主链。
- [x] 更新 smoke:禁止以手写 `.mnote-mindmap-node``path[data-testid="mindmap-edge"]` 作为成功条件。
- [x] 更新 smoke:断言真实 runtime mount 内存在 simple-mind-map 生成的 SVG/HTML 层、节点布局非零、连线来自 runtime 渲染层。
- [x] 更新 smoke:保存插入后、编辑后、reload 后截图,并人工对比 `image copy 60.png` / `image copy 70.png` 的工作台结构。
- [x] 运行 `cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap` 或等效 TS 单测,验证 adapter/diff 合同。
- [x] 重新构建 leptos-tiptap island。
- [x] 运行 `node scripts/task166-mindmap-phase6-block-smoke.js`
- [x] 只有 smoke 结果 JSON、截图和代码检查都证明真实 `simple-mind-map` runtime 已接入后,才允许勾选本 Task 完成。
备注:2026-05-10 首轮真实 runtime cutover 已通过;仍未勾选的 `view_data_change` 持久化与 toolbar 缩放绑定属于后续交互完善项,不能反向否定真实 runtime 已接入。
### Task 13: UI Shell Reuse 后续收口
## 依赖关系
**Current Checklist:**
- `design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- [ ] Task 1 完成后,才能开始代码实现,避免目标漂移
- [ ] Task 2 是 Task 3 和 Task 4 的共同前置
- [ ] Task 3 必须先于 Task 4、Task 5、Task 6、Task 7,否则 entry contract 和 block/page 主链没有稳定命令面
- [ ] Task 4 必须先于 Task 5 和 Task 7,否则 block / page 没有共享 contract 与 renderer adapter
- [ ] Task 5 是 block 主验收面的第一落点,必须先于 `/` 插入和 hover 体验
- [ ] Task 6 依赖 Task 5,不能先在旧主路径上做 UI 伪收口
- [ ] Task 7 依赖 Task 3 和 Task 4;建议在 Task 5 之后执行,确保 page 跟随 block 主链接入
- [ ] Task 8 不再与 Task 3 重复承担命令适配职责,只负责集中标记和隔离仍残留的 compat 区域
- [ ] Task 9 只在 Task 5/6/7/8 形成最小闭环后收尾
- [x] 2026-05-11 已将后续 UI 可用度收口迁入独立 checklist,目标是 `leptos-mindmap + simple-mind-map runtime + Leptos/Rust floating overlay UI shell`
- [x] 新 checklist 已完成 Task 5A/5B/6/7/8/9/10/11/12/13 的核心验收:floating overlay、Leptos/Rust shell、toolbar/sidebar/navigator/context menu、command bridge、smoke 截图与旧 React 边界。
- [x] 后续 KMind/lx-doc 视觉细化、图标体系、主题细节和高级面板内容按新 checklist 继续,不再把本旧计划的手写伪画布阶段作为 UI 完成判定。
## 退出标准
- [ ] 独立导图页主读链已不是 `mindmaps.get + 前端摘要`
- [ ] 文档内 `/` 插入的 `mindmap block` 已成为本轮主验收面,并挂在新 truth / adapter 主链上
- [ ] `simple-mind-map` 被明确降级为 renderer/editor adapter
- [ ] 至少一组最小编辑动作已走 Rust `MindmapOp` / command,而不是默认全量 blob 覆盖
- [ ] `/` 插入后默认生成示例骨架,且不是空白壳
- [ ] 默认隐藏 chromehover block / 节点后再显示相应操作
- [ ] 独立页与内嵌块共享同一套 projection truth 口径
- [ ] 仍未移除的 blob / localStorage / 附件路径都被清楚标成 compat
- [ ] 测试和 smoke 能证明 Phase 6 已经开始脱离 blob-first,而不是只换了命名
> 2026-05-10 现实复核后,本段退出标准重新打开。旧勾选只代表伪画布阶段的最小闭环,不能作为最终完成依据。
- [x] 文档页 `/` 插入 mindmap block 后,默认主链是 `leptos-tiptap` NodeView + 真实 `leptos-mindmap` adapter
- [x] 插入后截图可见 KMind-like 真实根节点、分支节点、概要和连线,且节点/连线由 `simple-mind-map` runtime 渲染。
- [x] `simple-mind-map` runtime 只作为 editor adapter,不是 canonical truth。
- [x] 最小节点编辑动作通过 Rust kernel command 回写,reload 后仍存在。
- [x] smoke 输出结构化 JSON 和截图证据,失败信息能定位到 projection、adapter、runtime、command 或 reload 阶段。
- [x] 旧 React/simple-mind-map block 不在 Phase 6 默认文档页主链内。
- [x] 默认 smoke 不再把手写 `.mnote-mindmap-node` / `path[data-testid="mindmap-edge"]` 当作完成条件。
## 暂不作为阻塞的尾项
- [ ] `Page Aggregate` 写后完整回灌统一 projection
- [ ] page subtree 与正文本地态完全同源
- [ ] tree realtime 全域单 live cache
- [ ] 导图 blob 存储彻底移除
- [ ] 全量画布命令 Rust 化
- [ ] Rust-native mindmap renderer。
- [ ] 完整复杂布局算法 Rust 化。
- [ ] KMind 多根节点。
- [ ] MOC / 文档树导图。
- [ ] 思源块悬浮预览与镜像块。
- [ ] PDF 标注跳转。
- [ ] 导图 blob 存储彻底移除。
- [ ] Page Aggregate 写后完整回灌统一 projection。
- [ ] tree realtime 全域单 live cache。
## 建议执行顺序
- [ ] 第 1 天:Task 1 + Task 2
- [ ] 第 2 天:Task 3
- [ ] 第 3 天:Task 4
- [ ] 第 4 天:Task 5
- [ ] 第 5 天Task 6
- [ ] 第 6 天:Task 7 + Task 8
- [ ] 第 7 天:Task 9 + 设计稿回填
- [x] 第 1 天:Task 1 + Task 2,冻结 projection 和 adapter scene contract。
- [x] 第 2 天:Task 3 + Task 4,审计参考实现并建立 simple-mind-map bridge。
- [x] 第 3 天:Task 5,打通 command bridge 和 adapter diff。
- [x] 第 4 天:Task 6 + Task 7 + Task 8,完成的是手写伪画布最小闭环,不是最终真实 runtime。
- [x] 下一步Task 12,真实 `simple-mind-map` runtime cutover,替换手写 DOM/SVG 画布。
- [x] Task 12 通过后:复核 Task 9 + Task 10 + Task 11,确认 legacy、独立页和验证口径没有回到伪画布。
- 2026-05-11 复核:新 UI shell checklist 已完成旧 React 边界注释、`editor-host-config` 静态断言和 `task166` smoke;默认成功条件为 `mnote-mindmap-editor-root``simple-mind-map-runtime``mindmap-rust-shell`
File diff suppressed because it is too large Load Diff
+191 -3
View File
@@ -51,9 +51,11 @@ pub use kernel::{
WorkspaceSourceCapability, WorkspaceSourceKind,
};
pub use mindmap::{
MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner,
MindmapTreeNode,
MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities,
MindmapKernelCommand, MindmapKernelEdge, MindmapKernelNode, MindmapKernelProjection,
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapProjection,
MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner, MindmapProjectionSource,
MindmapSummary, MindmapTreeNode,
};
pub use page_aggregate::{
PageAggregateProjection, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout,
@@ -363,4 +365,190 @@ mod tests {
assert_eq!(projection.owner, MindmapProjectionOwner::RustKernel);
assert!(matches!(command, MindmapCommand::RenameNode { .. }));
}
#[test]
fn mindmap_kernel_and_adapter_projection_serialize_as_camel_case() {
let kernel_projection = MindmapKernelProjection {
schema: "mnote.mindmap.kernel_projection.v1".into(),
document_id: "doc_1".into(),
mindmap_id: "mind_1".into(),
root_node_id: "root".into(),
revision: 7,
nodes: vec![
MindmapKernelNode {
node_id: "root".into(),
parent_id: None,
text: "KMIND".into(),
order: 0,
collapsed: false,
style: Default::default(),
refs: Default::default(),
},
MindmapKernelNode {
node_id: "child_1".into(),
parent_id: Some("root".into()),
text: "二级节点".into(),
order: 1,
collapsed: false,
style: Default::default(),
refs: Default::default(),
},
],
edges: vec![MindmapKernelEdge {
edge_id: "root->child_1".into(),
from_node_id: "root".into(),
to_node_id: "child_1".into(),
edge_kind: "tree".into(),
}],
summaries: vec![MindmapSummary {
summary_id: "summary_1".into(),
parent_node_id: "child_1".into(),
node_ids: vec!["child_1".into()],
text: "概要".into(),
style: Default::default(),
}],
associative_lines: vec![MindmapAssociativeLine {
line_id: "line_1".into(),
from_node_id: "root".into(),
to_node_id: "child_1".into(),
text: Some("关联".into()),
style: Default::default(),
}],
layout: serde_json::json!({ "type": "logicalStructure" }),
theme: serde_json::json!({ "template": "classic" }),
view: serde_json::json!({ "zoom": 1.0 }),
capabilities: MindmapKernelCapabilities {
can_edit: true,
can_edit_text: true,
can_insert_child: true,
can_insert_sibling_after: true,
can_delete_node: true,
can_move_node: true,
can_patch_view: true,
can_set_layout: true,
can_set_theme: true,
},
source: MindmapProjectionSource::Kernel,
};
let adapter_projection = MindmapAdapterProjection {
schema: "mnote.mindmap.simple_mind_map_scene.v1".into(),
runtime: "simple-mind-map".into(),
root: serde_json::json!({
"data": { "uid": "root", "text": "KMIND" },
"children": []
}),
layout: serde_json::json!("logicalStructure"),
theme: serde_json::json!("classic"),
theme_config: serde_json::json!({}),
view: serde_json::json!({ "scale": 1.0 }),
config: serde_json::json!({}),
compat_payload: serde_json::json!({}),
kernel_revision: 7,
};
let kernel_value =
serde_json::to_value(kernel_projection).expect("kernel projection should serialize");
let adapter_value =
serde_json::to_value(adapter_projection).expect("adapter projection should serialize");
assert_eq!(kernel_value["documentId"], serde_json::json!("doc_1"));
assert_eq!(kernel_value["mindmapId"], serde_json::json!("mind_1"));
assert_eq!(kernel_value["rootNodeId"], serde_json::json!("root"));
assert_eq!(
kernel_value["nodes"][1]["parentId"],
serde_json::json!("root")
);
assert_eq!(
kernel_value["associativeLines"][0]["fromNodeId"],
serde_json::json!("root")
);
assert_eq!(
kernel_value["capabilities"]["canEditText"],
serde_json::json!(true)
);
assert_eq!(kernel_value["source"], serde_json::json!("kernel"));
assert_eq!(
adapter_value["runtime"],
serde_json::json!("simple-mind-map")
);
assert_eq!(adapter_value["themeConfig"], serde_json::json!({}));
assert_eq!(adapter_value["compatPayload"], serde_json::json!({}));
assert_eq!(adapter_value["kernelRevision"], serde_json::json!(7));
}
#[test]
fn mindmap_kernel_command_contract_covers_minimum_write_surface() {
let commands = vec![
MindmapKernelCommand::UpdateText {
mindmap_id: "mind_1".into(),
node_id: "root".into(),
text: "新标题".into(),
},
MindmapKernelCommand::InsertChild {
mindmap_id: "mind_1".into(),
parent_node_id: "root".into(),
node: MindmapNodeInput {
uid: Some("child_1".into()),
text: "子节点".into(),
hyperlink: None,
note: None,
refs: None,
},
},
MindmapKernelCommand::InsertSiblingAfter {
mindmap_id: "mind_1".into(),
target_node_id: "child_1".into(),
node: MindmapNodeInput {
uid: Some("child_2".into()),
text: "同级节点".into(),
hyperlink: None,
note: None,
refs: None,
},
},
MindmapKernelCommand::DeleteNode {
mindmap_id: "mind_1".into(),
node_id: "child_2".into(),
},
MindmapKernelCommand::MoveNode {
mindmap_id: "mind_1".into(),
node_id: "child_1".into(),
new_parent_node_id: "root".into(),
order: Some(1),
},
MindmapKernelCommand::PatchView {
mindmap_id: "mind_1".into(),
patch: serde_json::json!({ "scale": 1.2 }),
},
MindmapKernelCommand::SetLayout {
mindmap_id: "mind_1".into(),
layout: serde_json::json!({ "type": "mindMap" }),
},
MindmapKernelCommand::SetTheme {
mindmap_id: "mind_1".into(),
theme: serde_json::json!({ "template": "classic" }),
theme_config: serde_json::json!({ "lineColor": "#f59e0b" }),
},
];
let encoded = serde_json::to_value(&commands).expect("commands should serialize");
assert_eq!(encoded[0]["type"], serde_json::json!("updateText"));
assert_eq!(encoded[1]["type"], serde_json::json!("insertChild"));
assert_eq!(encoded[2]["type"], serde_json::json!("insertSiblingAfter"));
assert_eq!(encoded[3]["type"], serde_json::json!("deleteNode"));
assert_eq!(encoded[4]["type"], serde_json::json!("moveNode"));
assert_eq!(encoded[5]["type"], serde_json::json!("patchView"));
assert_eq!(encoded[6]["type"], serde_json::json!("setLayout"));
assert_eq!(encoded[7]["type"], serde_json::json!("setTheme"));
assert_eq!(encoded[0]["mindmapId"], serde_json::json!("mind_1"));
assert_eq!(encoded[1]["parentNodeId"], serde_json::json!("root"));
assert_eq!(encoded[4]["newParentNodeId"], serde_json::json!("root"));
assert_eq!(
encoded[7]["themeConfig"]["lineColor"],
serde_json::json!("#f59e0b")
);
}
}
+187
View File
@@ -43,6 +43,189 @@ pub enum MindmapProjectionOwner {
CompatBlob,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum MindmapProjectionSource {
Kernel,
CompatBlob,
AdapterCache,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapKernelProjection {
pub schema: String,
pub document_id: String,
pub mindmap_id: String,
pub root_node_id: String,
pub revision: u64,
#[serde(default)]
pub nodes: Vec<MindmapKernelNode>,
#[serde(default)]
pub edges: Vec<MindmapKernelEdge>,
#[serde(default)]
pub summaries: Vec<MindmapSummary>,
#[serde(default)]
pub associative_lines: Vec<MindmapAssociativeLine>,
#[serde(default)]
pub layout: Value,
#[serde(default)]
pub theme: Value,
#[serde(default)]
pub view: Value,
pub capabilities: MindmapKernelCapabilities,
pub source: MindmapProjectionSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapKernelNode {
pub node_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub text: String,
#[serde(default)]
pub order: i64,
#[serde(default)]
pub collapsed: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub style: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub refs: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapKernelEdge {
pub edge_id: String,
pub from_node_id: String,
pub to_node_id: String,
pub edge_kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapSummary {
pub summary_id: String,
pub parent_node_id: String,
#[serde(default)]
pub node_ids: Vec<String>,
pub text: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub style: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapAssociativeLine {
pub line_id: String,
pub from_node_id: String,
pub to_node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub style: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapKernelCapabilities {
pub can_edit: bool,
pub can_edit_text: bool,
pub can_insert_child: bool,
pub can_insert_sibling_after: bool,
pub can_delete_node: bool,
pub can_move_node: bool,
pub can_patch_view: bool,
pub can_set_layout: bool,
pub can_set_theme: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapAdapterProjection {
pub schema: String,
pub runtime: String,
pub root: Value,
#[serde(default)]
pub layout: Value,
#[serde(default)]
pub theme: Value,
#[serde(default)]
pub theme_config: Value,
#[serde(default)]
pub view: Value,
#[serde(default)]
pub config: Value,
#[serde(default)]
pub compat_payload: Value,
pub kernel_revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum MindmapKernelCommand {
UpdateText {
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
mindmap_id: String,
#[serde(rename = "nodeId", alias = "node_id")]
node_id: String,
text: String,
},
InsertChild {
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
mindmap_id: String,
#[serde(rename = "parentNodeId", alias = "parent_node_id", alias = "parentId")]
parent_node_id: String,
node: MindmapNodeInput,
},
InsertSiblingAfter {
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
mindmap_id: String,
#[serde(rename = "targetNodeId", alias = "target_node_id")]
target_node_id: String,
node: MindmapNodeInput,
},
DeleteNode {
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
mindmap_id: String,
#[serde(rename = "nodeId", alias = "node_id")]
node_id: String,
},
MoveNode {
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
mindmap_id: String,
#[serde(rename = "nodeId", alias = "node_id")]
node_id: String,
#[serde(
rename = "newParentNodeId",
alias = "new_parent_node_id",
alias = "newParentId"
)]
new_parent_node_id: String,
#[serde(rename = "order", alias = "sortOrder", alias = "sort_order")]
order: Option<i64>,
},
PatchView {
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
mindmap_id: String,
patch: Value,
},
SetLayout {
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
mindmap_id: String,
layout: Value,
},
SetTheme {
#[serde(rename = "mindmapId", alias = "mindmap_id", alias = "mapId")]
mindmap_id: String,
theme: Value,
#[serde(default)]
#[serde(rename = "themeConfig", alias = "theme_config")]
theme_config: Value,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum MindmapCommand {
@@ -136,6 +319,8 @@ pub struct MindmapTreeNode {
pub extra: BTreeMap<String, Value>,
}
/// 兼容 DTO:对应 simple-mind-map / KMind 风格树节点。
/// 长期事实源应使用 `MindmapKernelProjection` 与 kernel command。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MindmapNodeInput {
@@ -150,6 +335,8 @@ pub struct MindmapNodeInput {
pub refs: Option<Vec<MindmapNodeRef>>,
}
/// 兼容 DTO:用于历史 AI 工具和旧 blob 操作。
/// 新的 leptos-mindmap 写面应优先使用 `MindmapKernelCommand`。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "op", rename_all = "camelCase")]
pub enum MindmapOp {
@@ -0,0 +1,207 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex;
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id,
};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
apply_mindmap_kernel_commands_to_value, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MindmapQueryParams {
pub view: Option<String>,
pub query_name: Option<String>,
pub root_node_id: Option<String>,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MindmapCommandRequest {
pub command_name: Option<String>,
#[serde(default)]
pub commands: Vec<Value>,
pub projection_revision: Option<u64>,
pub workspace_id: Option<String>,
}
fn response_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_MINDMAP_TRANSPORT.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mindmap-api"));
}
headers
}
fn resolve_query_name(params: &MindmapQueryParams) -> &'static str {
if params.query_name.as_deref() == Some("mindmap.simple_mind_map_scene.get")
|| params.view.as_deref() == Some("simple_mind_map_scene")
{
return "mindmap.simple_mind_map_scene.get";
}
if params.query_name.as_deref() == Some("mindmap.kernel_projection.get")
|| params.view.as_deref() == Some("kernel_projection")
{
return "mindmap.kernel_projection.get";
}
"mindmap.projection.get"
}
pub async fn get_mindmap(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path((document_id, mindmap_id)): Path<(String, String)>,
Query(params): Query<MindmapQueryParams>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = document_id.trim();
let mindmap_id = mindmap_id.trim();
if document_id.is_empty() || mindmap_id.is_empty() {
return Err(WebError::bad_request_code(
"mindmap_id_required",
"缺少有效 documentId 或 mindmapId",
)
.with_context(&context));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?;
let query_name = resolve_query_name(&params);
let result = execute_runtime_query_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: query_name.into(),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"rootNodeId": params.root_node_id,
"workspaceId": effective_workspace_id.clone(),
}),
},
)
.await?;
Ok((StatusCode::OK, response_headers(), Json(result)))
}
pub async fn apply_mindmap_command(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path((document_id, mindmap_id)): Path<(String, String)>,
Json(body): Json<MindmapCommandRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = document_id.trim();
let mindmap_id = mindmap_id.trim();
if document_id.is_empty() || mindmap_id.is_empty() {
return Err(WebError::bad_request_code(
"mindmap_id_required",
"缺少有效 documentId 或 mindmapId",
)
.with_context(&context));
}
if body.command_name.as_deref() != Some("mindmap.command.apply") {
return Err(WebError::bad_request_code(
"mindmap_command_required",
"仅支持 mindmap.command.apply",
)
.with_context(&context));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
let current = fetch_query_data_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: "mindmaps.get".into(),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"workspaceId": effective_workspace_id,
}),
},
)
.await?;
let applied = apply_mindmap_kernel_commands_to_value(&current, &body.commands)
.map_err(|error| WebError::bad_request(error.message).with_context(&context))?;
if !applied.errors.is_empty() {
return Err(WebError::bad_request_code(
"mindmap_command_failed",
applied.errors.join("; "),
)
.with_context(&context)
.with_header("x-error-phase", "mindmap_command_apply"));
}
let command = RuntimeCommandEnvelopeWire {
name: "mindmaps.put".into(),
command_id: format!("mindmap_put_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
page_id: Some(document_id.to_string()),
block_id: Some(mindmap_id.to_string()),
}),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"workspaceId": effective_workspace_id,
"data": applied.data,
"createOnly": false,
}),
preflight_data: None,
reason: Some("mnote-web mindmap command apply via kernel projection".into()),
refs: vec!["task166-mindmap-phase6-block-smoke".into()],
dry_run: false,
validate_only: false,
};
let result = execute_runtime_command_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
Ok((
StatusCode::OK,
response_headers(),
Json(json!({
"ok": true,
"commandName": "mindmap.command.apply",
"applied": applied.applied,
"errors": applied.errors,
"projectionRevision": body.projection_revision,
"result": result,
})),
))
}
@@ -20,15 +20,16 @@ pub async fn mindmap_object_shell(
"documentId": doc_id,
"mindmapId": mindmap_id,
"projection": {
"schema": "mnote.mindmap_projection.v1",
"source": "rust-kernel",
"schema": "mnote.mindmap.simple_mind_map_scene.v1",
"runtime": "simple-mind-map",
"source": "compat-blob",
"owner": "rust-kernel",
"queryName": "mindmap.projection.get"
"queryName": "mindmap.simple_mind_map_scene.get"
},
"island": {
"kind": "react_mindmap_runtime",
"kind": "leptos_mindmap_adapter",
"mountId": "mnote-mindmap-island",
"runtimeRole": "renderer_adapter",
"runtimeRole": "simple_mind_map_adapter",
"commandName": "mindmap.command.apply"
},
"requestId": context.trace.request_id,
@@ -115,6 +116,47 @@ mod tests {
}))
}
fn app_with_mindmap_fixture() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(
serde_json::json!({
"mindmaps:get": {
"ok": true,
"source": "compat-blob",
"revision": 7,
"data": {
"data": {"uid": "root", "text": "KMIND"},
"children": [
{"data": {"uid": "topic", "text": "二级节点"}, "children": []}
]
},
"meta": {
"document_id": "doc_1",
"mindmap_id": "mind_1"
}
}
})
.to_string(),
),
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn mindmap_shell_returns_rust_object_shell_contract() {
let response = app()
@@ -147,9 +189,42 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("mnote.mindmap_shell.v1"));
assert!(html.contains("data-react-island=\"mindmap_runtime\""));
assert!(html.contains("mindmap.projection.get"));
assert!(html.contains("data-leptos-mindmap-island=\"standalone\""));
assert!(html.contains("mindmap.simple_mind_map_scene.get"));
assert!(html.contains("mindmap.command.apply"));
assert!(!html.contains("react_mindmap_runtime"));
assert!(!html.contains("next-app-router"));
}
#[tokio::test]
async fn mindmap_api_returns_same_adapter_contract_for_standalone_and_block() {
let response = app_with_mindmap_fixture()
.oneshot(
Request::builder()
.uri("/api/mindmap/doc_1/mind_1?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("adapter projection json");
assert_eq!(
payload["schema"],
serde_json::json!("mnote.mindmap.simple_mind_map_scene.v1")
);
assert_eq!(payload["runtime"], serde_json::json!("simple-mind-map"));
assert_eq!(payload["root"]["data"]["uid"], serde_json::json!("root"));
assert_eq!(payload["root"]["data"]["text"], serde_json::json!("KMIND"));
assert_eq!(payload["kernelRevision"], serde_json::json!(7));
assert_eq!(
payload["compatPayload"]["source"],
serde_json::json!("compat-blob")
);
}
}
+5
View File
@@ -11,6 +11,7 @@ mod local_folder_source;
mod local_folder_events;
mod local_markdown_parser;
mod media;
mod mindmap_api;
mod mindmap_shell;
mod onlyoffice;
mod query_support;
@@ -42,6 +43,10 @@ pub fn build_router(state: AppState) -> Router {
"/mindmap/{doc_id}/{mindmap_id}",
get(mindmap_shell::mindmap_object_shell),
)
.route(
"/api/mindmap/{doc_id}/{mindmap_id}",
get(mindmap_api::get_mindmap).post(mindmap_api::apply_mindmap_command),
)
.route(
"/documents/{document_id}",
get(web_shell::document_page_shell),
@@ -11,7 +11,7 @@ use leptos::prelude::*;
/// 渲染思维导图的 SSR 壳结构:
/// - `<main id="mnote-mindmap-shell" data-object-shell="mindmap">`
/// - 思维导图标题区域
/// - React 运行时挂载占位区
/// - leptos-mindmap / simple-mind-map adapter 挂载占位区
#[component]
pub fn MindmapPage(
/// 文档 ID
@@ -30,7 +30,13 @@ pub fn MindmapPage(
<header>
<h1>{"思维导图"}</h1>
</header>
<section id="mnote-mindmap-island" data-react-island="mindmap_runtime"></section>
<section
id="mnote-mindmap-island"
data-leptos-mindmap-island="standalone"
data-runtime="simple-mind-map"
data-projection-query="mindmap.simple_mind_map_scene.get"
data-command-name="mindmap.command.apply"
></section>
</main>
</PageLayout>
}
@@ -38,12 +38,18 @@ export class IntoUnderlyingSource {
export function mount(container: Element, options: any): number;
export function mount_mindmap_shell(container: Element, options: any): number;
export function unmount(mount_id: number): void;
export function unmount_mindmap_shell(mount_id: number): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly mount_mindmap_shell: (a: any, b: any) => [number, number, number];
readonly unmount_mindmap_shell: (a: number) => [number, number];
readonly mount: (a: any, b: any) => [number, number, number];
readonly unmount: (a: number) => [number, number];
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
@@ -61,7 +67,7 @@ export interface InitOutput {
readonly intounderlyingsource_cancel: (a: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
@@ -165,6 +165,19 @@ export function mount(container, options) {
return ret[0] >>> 0;
}
/**
* @param {Element} container
* @param {any} options
* @returns {number}
*/
export function mount_mindmap_shell(container, options) {
const ret = wasm.mount_mindmap_shell(container, options);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return ret[0] >>> 0;
}
/**
* @param {number} mount_id
*/
@@ -174,6 +187,16 @@ export function unmount(mount_id) {
throw takeFromExternrefTable0(ret[0]);
}
}
/**
* @param {number} mount_id
*/
export function unmount_mindmap_shell(mount_id) {
const ret = wasm.unmount_mindmap_shell(mount_id);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
@@ -812,6 +835,10 @@ function __wbg_get_imports() {
const ret = arg0.next;
return ret;
},
__wbg_now_a9b7df1cbee90986: function() {
const ret = Date.now();
return ret;
},
__wbg_ok_f7783a2e6ac7fe17: function(arg0) {
const ret = arg0.ok;
return ret;
@@ -1233,42 +1260,42 @@ function __wbg_get_imports() {
}
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1440, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1585, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1721, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1824, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1812, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1915, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1638, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1741, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1723, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1826, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1637, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1740, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1659, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1762, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1722, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1825, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
return ret;
},
@@ -1330,8 +1357,8 @@ function wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg
return ret !== 0;
}
function wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2) {
@@ -1,6 +1,8 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const mount_mindmap_shell: (a: any, b: any) => [number, number, number];
export const unmount_mindmap_shell: (a: number) => [number, number];
export const mount: (a: any, b: any) => [number, number, number];
export const unmount: (a: number) => [number, number];
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
@@ -18,7 +20,7 @@ export const intounderlyingsource_pull: (a: number, b: any) => any;
export const intounderlyingsource_cancel: (a: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__had2dfed707e9250e: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,978 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
renameDocument,
} = require("./tree-shell-smoke-helpers");
const TASK = "task166-mindmap-phase6-block-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function checkPort(port) {
const result = spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
encoding: "utf8",
stdio: "pipe",
});
const lines = (result.stdout || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const rows = lines.slice(1).map((line) => {
const parts = line.split(/\s+/);
const pid = parts[1] || "";
const commandLine =
pid.length > 0
? spawnSync("ps", ["-p", pid, "-o", "command="], {
encoding: "utf8",
stdio: "pipe",
}).stdout.trim()
: "";
return {
command: parts[0] || "",
pid,
commandLine,
name: parts.slice(8).join(" "),
raw: line,
};
});
return { port, rows };
}
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function screenshot(page, name) {
const file = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: file, fullPage: true });
return file;
}
async function readMindmapMetrics(page) {
return page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
const runtime = document.querySelector('[data-testid="simple-mind-map-runtime"]');
const scene = root instanceof HTMLElement ? root.querySelector('[data-testid="leptos-mindmap-island"]') : null;
const overlayLayer = root instanceof HTMLElement ? root.querySelector('[data-testid="mindmap-overlay-layer"]') : null;
const canvasLayer = root instanceof HTMLElement ? root.querySelector('[data-testid="mindmap-canvas-layer"]') : null;
const schemaToolbar = document.querySelector('[data-testid="mindmap-schema-toolbar"]');
const schemaSidebar = document.querySelector('[data-testid="mindmap-schema-sidebar"]');
const schemaNavigator = document.querySelector('[data-testid="mindmap-schema-navigator"]');
const schemaCount = document.querySelector('[data-testid="mindmap-schema-count"]');
const schemaContextMenu = document.querySelector('[data-testid="mindmap-schema-context-menu"]');
const rustShellMount = document.querySelector('[data-testid="mindmap-rust-shell-mount"]');
const rustShell = document.querySelector('[data-testid="mindmap-rust-shell"]');
const error = document.querySelector('[data-testid="leptos-mindmap-error"]');
const mindmapId = root instanceof HTMLElement ? root.dataset.mnoteMindmapId || null : null;
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
const bridge = mindmapId ? registry[mindmapId] : null;
const snapshot = bridge && typeof bridge.getSnapshot === "function" ? bridge.getSnapshot() : null;
const canonicalRoot =
snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) && "root" in snapshot
? snapshot.root
: snapshot;
const snapshotStats = { nodeCount: 0, edgeCount: 0, nodeTexts: [] };
const visit = (node) => {
if (!node || typeof node !== "object" || Array.isArray(node)) return;
snapshotStats.nodeCount += 1;
const text = node.data && typeof node.data.text === "string" ? node.data.text.trim() : "";
if (text) snapshotStats.nodeTexts.push(text);
const children = Array.isArray(node.children) ? node.children : [];
snapshotStats.edgeCount += children.length;
children.forEach(visit);
};
visit(canonicalRoot);
const runtimeNodes = runtime
? Array.from(runtime.querySelectorAll(".smm-node")).filter((node) => {
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}).length
: 0;
const svgPaths = runtime
? Array.from(runtime.querySelectorAll("svg path, svg line, svg polyline")).filter((edge) => {
const rect = edge.getBoundingClientRect();
return rect.width > 0 || rect.height > 0 || edge.getAttribute("d") || edge.getAttribute("points");
}).length
: 0;
const runtimeRect = runtime?.getBoundingClientRect();
const rootRect = root?.getBoundingClientRect();
const overlayRect = overlayLayer?.getBoundingClientRect();
const canvasRect = canvasLayer?.getBoundingClientRect();
return {
errorStage: error instanceof HTMLElement ? error.dataset.stage || "unknown_error" : null,
rootExists: root instanceof HTMLElement,
mindmapId,
runtimeExists: runtime instanceof HTMLElement,
runtimeEngine: runtime instanceof HTMLElement ? runtime.dataset.runtimeEngine || null : null,
runtimeReady: runtime instanceof HTMLElement ? runtime.dataset.runtimeReady === "true" : false,
lastViewEvent:
root instanceof HTMLElement
? root.querySelector('[data-testid="leptos-mindmap-island"]')?.dataset.lastViewEvent || null
: null,
debugChrome:
scene instanceof HTMLElement ? scene.dataset.debugChrome || null : null,
uiShell:
scene instanceof HTMLElement ? scene.dataset.uiShell || null : null,
rustShell: {
mount: rustShellMount instanceof HTMLElement,
mountSource: rustShellMount instanceof HTMLElement ? rustShellMount.dataset.uiShellSource || null : null,
shell: rustShell instanceof HTMLElement,
shellSource: rustShell instanceof HTMLElement ? rustShell.dataset.uiShellSource || null : null,
},
schemaChrome: {
toolbar:
schemaToolbar instanceof HTMLElement &&
schemaToolbar.dataset.schemaSource === "mindmapDefaultUiSchema" &&
schemaToolbar.dataset.uiSource === "leptos-rust-shell",
sidebar:
schemaSidebar instanceof HTMLElement &&
schemaSidebar.dataset.schemaSource === "mindmapDefaultUiSchema" &&
schemaSidebar.dataset.uiSource === "leptos-rust-shell",
navigator:
schemaNavigator instanceof HTMLElement &&
schemaNavigator.dataset.schemaSource === "mindmapDefaultUiSchema" &&
schemaNavigator.dataset.uiSource === "leptos-rust-shell",
count:
schemaCount instanceof HTMLElement &&
schemaCount.dataset.schemaSource === "mindmapDefaultUiSchema" &&
schemaCount.dataset.uiSource === "leptos-rust-shell",
toolbarActions: Array.from(document.querySelectorAll("[data-mindmap-action-id]"))
.map((item) => item instanceof HTMLElement ? item.dataset.mindmapActionId : null)
.filter(Boolean),
disabledActions: Array.from(document.querySelectorAll("[data-mindmap-action-id]"))
.filter((item) => item instanceof HTMLButtonElement && item.disabled)
.map((item) => item.dataset.mindmapActionId)
.filter(Boolean),
sidebarPanels: Array.from(document.querySelectorAll("[data-mindmap-sidebar-panel-id]"))
.map((item) => item instanceof HTMLElement ? item.dataset.mindmapSidebarPanelId : null)
.filter(Boolean),
statsText: schemaNavigator?.querySelector('[data-testid="mindmap-schema-navigator-stats"]')?.textContent || null,
countText: schemaCount?.textContent || null,
zoomText: schemaNavigator?.querySelector('[data-testid="mindmap-schema-navigator-zoom"]')?.textContent || null,
minimap: Boolean(document.querySelector('[data-testid="mindmap-schema-minimap"]')),
contextMenu:
schemaContextMenu instanceof HTMLElement
? {
kind: schemaContextMenu.dataset.contextMenuKind || null,
actions: Array.from(schemaContextMenu.querySelectorAll("[data-mindmap-context-action-id]"))
.map((item) => item instanceof HTMLElement ? item.dataset.mindmapContextActionId : null)
.filter(Boolean),
}
: null,
},
overlayShell: {
overlay: overlayLayer instanceof HTMLElement,
canvas: canvasLayer instanceof HTMLElement,
rootBox: rootRect
? { width: rootRect.width, height: rootRect.height }
: { width: 0, height: 0 },
overlayBox: overlayRect
? { width: overlayRect.width, height: overlayRect.height }
: { width: 0, height: 0 },
canvasBox: canvasRect
? { width: canvasRect.width, height: canvasRect.height }
: { width: 0, height: 0 },
},
legacyFallback: {
toolbar: Boolean(document.querySelector('[data-testid="mindmap-command-toolbar"]')),
sidebar: Boolean(document.querySelector('[data-testid="mindmap-sidebar"]')),
bottomBar: Boolean(document.querySelector('[data-testid="mindmap-bottom-bar"]')),
},
viewCommandStatus:
scene instanceof HTMLElement ? scene.dataset.viewCommandStatus || null : null,
bridgeExists: Boolean(bridge),
runtimeBox: runtimeRect
? { width: runtimeRect.width, height: runtimeRect.height }
: { width: 0, height: 0 },
nodeCount: snapshotStats.nodeCount || runtimeNodes,
edgeCount: snapshotStats.edgeCount || svgPaths,
runtimeNodeCount: runtimeNodes,
svgPathCount: svgPaths,
nodeTexts: snapshotStats.nodeTexts,
};
});
}
async function assertMindmapReady(page, stage) {
await page.locator('[data-testid="mnote-mindmap-editor-root"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="simple-mind-map-runtime"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const root = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
const runtime = document.querySelector('[data-testid="simple-mind-map-runtime"]');
const mindmapId = root instanceof HTMLElement ? root.dataset.mnoteMindmapId || null : null;
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
const bridge = mindmapId ? registry[mindmapId] : null;
return (
runtime instanceof HTMLElement &&
runtime.dataset.runtimeEngine === "simple-mind-map" &&
runtime.dataset.runtimeReady === "true" &&
Boolean(bridge)
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const metrics = await readMindmapMetrics(page);
if (metrics.errorStage) {
throw new Error(`${metrics.errorStage}: ${stage}`);
}
assert(metrics.rootExists, `${stage}: mnote-mindmap-editor-root 不存在`);
assert(metrics.runtimeExists, `${stage}: runtime_render_failed`);
assert(metrics.runtimeEngine === "simple-mind-map", `${stage}: runtime_engine_failed`);
assert(metrics.runtimeReady, `${stage}: runtime_not_ready`);
assert(metrics.bridgeExists, `${stage}: bridge_not_registered`);
assert(metrics.runtimeBox.width > 0 && metrics.runtimeBox.height > 0, `${stage}: runtime_render_failed`);
assert(metrics.debugChrome === "false", `${stage}: debug_chrome_unexpected`);
assert(metrics.uiShell === "leptos-rust-shell", `${stage}: ui_shell_source_unexpected`);
assert(metrics.rustShell.mount, `${stage}: rust_shell_mount_missing`);
assert(metrics.rustShell.mountSource === "leptos-rust-shell", `${stage}: rust_shell_mount_source_unexpected`);
assert(metrics.rustShell.shell, `${stage}: rust_shell_missing`);
assert(metrics.rustShell.shellSource === "leptos-rust-shell", `${stage}: rust_shell_source_unexpected`);
assert(metrics.schemaChrome.toolbar, `${stage}: ui_shell_missing toolbar`);
assert(metrics.schemaChrome.sidebar, `${stage}: ui_shell_missing sidebar`);
assert(metrics.schemaChrome.navigator, `${stage}: ui_shell_missing navigator`);
assert(metrics.schemaChrome.count, `${stage}: ui_shell_missing count`);
assert(metrics.overlayShell.overlay, `${stage}: floating_overlay_missing overlay`);
assert(metrics.overlayShell.canvas, `${stage}: floating_overlay_missing canvas`);
assert(metrics.runtimeBox.width / Math.max(metrics.overlayShell.rootBox.width, 1) >= 0.9, `${stage}: runtime_width_squeezed`);
assert(metrics.runtimeBox.height / Math.max(metrics.overlayShell.rootBox.height, 1) >= 0.85, `${stage}: runtime_height_squeezed`);
assert(!metrics.legacyFallback.toolbar, `${stage}: legacy_toolbar_unexpected`);
assert(!metrics.legacyFallback.sidebar, `${stage}: legacy_sidebar_unexpected`);
assert(!metrics.legacyFallback.bottomBar, `${stage}: legacy_bottom_bar_unexpected`);
const expectedActions = [
"undo",
"redo",
"insertSiblingAfter",
"insertChild",
"deleteNode",
"tag",
"hyperlink",
"note",
"image",
"icon",
"summary",
"associativeLine",
"formula",
"painter",
"import",
"export",
];
for (const actionId of expectedActions) {
assert(metrics.schemaChrome.toolbarActions.includes(actionId), `${stage}: ui_shell_missing action=${actionId}`);
}
for (const panelId of ["nodeStyle", "baseStyle", "theme", "structure", "outline"]) {
assert(metrics.schemaChrome.sidebarPanels.includes(panelId), `${stage}: ui_shell_missing panel=${panelId}`);
}
assert(/节点/.test(metrics.schemaChrome.countText || ""), `${stage}: ui_shell_missing count_stats`);
assert(/%/.test(metrics.schemaChrome.zoomText || ""), `${stage}: ui_shell_missing zoom`);
assert(metrics.nodeCount >= 4, `${stage}: runtime_render_failed nodeCount=${metrics.nodeCount}`);
assert(metrics.edgeCount >= 3, `${stage}: runtime_render_failed edgeCount=${metrics.edgeCount}`);
return metrics;
}
async function readMindmapSnapshotStats(page, mindmapId) {
return page.evaluate((mindmapId) => {
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
const bridge = registry[mindmapId];
const snapshot = bridge && typeof bridge.getSnapshot === "function" ? bridge.getSnapshot() : null;
const root =
snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) && "root" in snapshot
? snapshot.root
: snapshot;
const stats = { nodeCount: 0, edgeCount: 0, nodeTexts: [] };
const visit = (node) => {
if (!node || typeof node !== "object" || Array.isArray(node)) return;
stats.nodeCount += 1;
const text = node.data && typeof node.data.text === "string" ? node.data.text.trim() : "";
if (text) stats.nodeTexts.push(text);
const children = Array.isArray(node.children) ? node.children : [];
stats.edgeCount += children.length;
children.forEach(visit);
};
visit(root);
return stats;
}, mindmapId);
}
async function clickToolbarActionAndWaitForCommand(page, actionId) {
await page.evaluate(() => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
if (scene instanceof HTMLElement) {
scene.dataset.commandStatus = "smoke-waiting";
delete scene.dataset.lastSchemaAction;
}
});
await page.getByTestId(`mindmap-schema-toolbar-action-${actionId}`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(actionId) => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
return (
scene instanceof HTMLElement &&
scene.dataset.lastSchemaAction === actionId &&
scene.dataset.commandStatus === "success"
);
},
actionId,
{ timeout: UI_TIMEOUT_MS },
).catch(async (error) => {
const diagnostics = await page.evaluate((actionId) => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
const button = document.querySelector(`[data-testid="mindmap-schema-toolbar-action-${actionId}"]`);
const errorLayer = document.querySelector('[data-testid="leptos-mindmap-error"]');
return {
sceneDataset: scene instanceof HTMLElement ? { ...scene.dataset } : null,
buttonDisabled: button instanceof HTMLButtonElement ? button.disabled : null,
buttonExists: button instanceof HTMLElement,
errorStage: errorLayer instanceof HTMLElement ? errorLayer.getAttribute("data-stage") : null,
errorText: errorLayer instanceof HTMLElement ? errorLayer.textContent : null,
};
}, actionId);
throw new Error(`command_failed:${actionId}:command_status_timeout:${JSON.stringify(diagnostics)}:${error.message}`);
});
return { ok: () => true };
}
async function clickSidebarOptionAndWaitForCommand(page, optionId, actionId) {
await page.evaluate(() => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
if (scene instanceof HTMLElement) {
scene.dataset.commandStatus = "smoke-waiting";
delete scene.dataset.lastSchemaAction;
delete scene.dataset.lastSchemaCommand;
}
});
await page.getByTestId(`mindmap-schema-sidebar-option-${optionId}`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(actionId) => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
return (
scene instanceof HTMLElement &&
scene.dataset.lastSchemaAction === actionId &&
scene.dataset.commandStatus === "success"
);
},
actionId,
{ timeout: UI_TIMEOUT_MS },
).catch(async (error) => {
const diagnostics = await page.evaluate((optionId) => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
const option = document.querySelector(`[data-testid="mindmap-schema-sidebar-option-${optionId}"]`);
const errorLayer = document.querySelector('[data-testid="leptos-mindmap-error"]');
return {
sceneDataset: scene instanceof HTMLElement ? { ...scene.dataset } : null,
optionExists: option instanceof HTMLElement,
errorStage: errorLayer instanceof HTMLElement ? errorLayer.getAttribute("data-stage") : null,
errorText: errorLayer instanceof HTMLElement ? errorLayer.textContent : null,
};
}, optionId);
throw new Error(`command_failed:${actionId}:${optionId}:command_status_timeout:${JSON.stringify(diagnostics)}:${error.message}`);
});
}
async function verifyReadonlyToolbarState(page, stage) {
await page.getByTestId("mindmap-schema-navigator-action-readonly").click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const insertChild = document.querySelector('[data-testid="mindmap-schema-toolbar-action-insertChild"]');
const deleteNode = document.querySelector('[data-testid="mindmap-schema-toolbar-action-deleteNode"]');
const zoomIn = document.querySelector('[data-testid="mindmap-schema-navigator-action-zoomIn"]');
const centerRoot = document.querySelector('[data-testid="mindmap-schema-navigator-action-centerRoot"]');
return (
insertChild instanceof HTMLButtonElement &&
deleteNode instanceof HTMLButtonElement &&
zoomIn instanceof HTMLButtonElement &&
centerRoot instanceof HTMLButtonElement &&
insertChild.disabled &&
deleteNode.disabled &&
!zoomIn.disabled &&
!centerRoot.disabled
);
},
null,
{ timeout: UI_TIMEOUT_MS },
).catch((error) => {
throw new Error(`ui_shell_missing:${stage}:readonly_disabled_state:${error.message}`);
});
await page.getByTestId("mindmap-schema-navigator-action-readonly").click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const insertChild = document.querySelector('[data-testid="mindmap-schema-toolbar-action-insertChild"]');
return insertChild instanceof HTMLButtonElement && !insertChild.disabled;
},
null,
{ timeout: UI_TIMEOUT_MS },
);
return true;
}
async function exerciseToolbarNodeActions(page, documentId, mindmapId) {
const before = await readMindmapSnapshotStats(page, mindmapId);
const childResponse = await clickToolbarActionAndWaitForCommand(page, "insertChild");
await page.waitForFunction(
({ mindmapId, expected }) => {
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
const bridge = registry[mindmapId];
const snapshot = bridge && typeof bridge.getSnapshot === "function" ? bridge.getSnapshot() : null;
const root =
snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) && "root" in snapshot
? snapshot.root
: snapshot;
let count = 0;
const visit = (node) => {
if (!node || typeof node !== "object" || Array.isArray(node)) return;
count += 1;
if (Array.isArray(node.children)) node.children.forEach(visit);
};
visit(root);
return count >= expected;
},
{ mindmapId, expected: before.nodeCount + 1 },
{ timeout: UI_TIMEOUT_MS },
);
const afterChild = await readMindmapSnapshotStats(page, mindmapId);
const siblingResponse = await clickToolbarActionAndWaitForCommand(page, "insertSiblingAfter");
await page.waitForFunction(
({ mindmapId, expected }) => {
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
const bridge = registry[mindmapId];
const snapshot = bridge && typeof bridge.getSnapshot === "function" ? bridge.getSnapshot() : null;
const root =
snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) && "root" in snapshot
? snapshot.root
: snapshot;
let count = 0;
const visit = (node) => {
if (!node || typeof node !== "object" || Array.isArray(node)) return;
count += 1;
if (Array.isArray(node.children)) node.children.forEach(visit);
};
visit(root);
return count >= expected;
},
{ mindmapId, expected: afterChild.nodeCount + 1 },
{ timeout: UI_TIMEOUT_MS },
);
const afterSibling = await readMindmapSnapshotStats(page, mindmapId);
const deleteResponse = await clickToolbarActionAndWaitForCommand(page, "deleteNode");
await page.waitForFunction(
({ mindmapId, expectedMax }) => {
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
const bridge = registry[mindmapId];
const snapshot = bridge && typeof bridge.getSnapshot === "function" ? bridge.getSnapshot() : null;
const root =
snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) && "root" in snapshot
? snapshot.root
: snapshot;
let count = 0;
const visit = (node) => {
if (!node || typeof node !== "object" || Array.isArray(node)) return;
count += 1;
if (Array.isArray(node.children)) node.children.forEach(visit);
};
visit(root);
return count <= expectedMax;
},
{ mindmapId, expectedMax: afterSibling.nodeCount - 1 },
{ timeout: UI_TIMEOUT_MS },
);
const afterDelete = await readMindmapSnapshotStats(page, mindmapId);
return {
insertChildOk: childResponse.ok() && afterChild.nodeCount >= before.nodeCount + 1,
insertSiblingOk: siblingResponse.ok() && afterSibling.nodeCount >= afterChild.nodeCount + 1,
deleteOk: deleteResponse.ok() && afterDelete.nodeCount <= afterSibling.nodeCount - 1,
beforeNodeCount: before.nodeCount,
afterChildNodeCount: afterChild.nodeCount,
afterSiblingNodeCount: afterSibling.nodeCount,
afterDeleteNodeCount: afterDelete.nodeCount,
};
}
async function exerciseSidebarActions(page, documentId, mindmapId) {
await page.getByTestId("mindmap-schema-sidebar-tab-theme").click({ timeout: UI_TIMEOUT_MS });
await clickSidebarOptionAndWaitForCommand(page, "theme-classic4", "setTheme");
const themedProjection = await fetchAdapterProjection(page, documentId, mindmapId);
if (themedProjection?.theme !== "classic4") {
throw new Error(`command_failed:setTheme theme=${JSON.stringify(themedProjection?.theme)}`);
}
await page.getByTestId("mindmap-schema-sidebar-tab-structure").click({ timeout: UI_TIMEOUT_MS });
await clickSidebarOptionAndWaitForCommand(page, "layout-mind-map", "setLayout");
const layoutProjection = await fetchAdapterProjection(page, documentId, mindmapId);
if (layoutProjection?.layout !== "mindMap") {
throw new Error(`command_failed:setLayout layout=${JSON.stringify(layoutProjection?.layout)}`);
}
await page.getByTestId("mindmap-schema-sidebar-tab-nodeStyle").click({ timeout: UI_TIMEOUT_MS });
await clickSidebarOptionAndWaitForCommand(page, "node-fill-blue", "painter");
const nodeStyleProjection = await fetchAdapterProjection(page, documentId, mindmapId);
const rootFill = nodeStyleProjection?.root?.data?.fillColor;
if (rootFill !== "#dbeafe") {
throw new Error(`command_failed:nodeStyle compat fill=${JSON.stringify(rootFill)}`);
}
await page.getByTestId("mindmap-schema-sidebar-tab-baseStyle").click({ timeout: UI_TIMEOUT_MS });
await clickSidebarOptionAndWaitForCommand(page, "base-curve-line", "painter");
const baseStyleProjection = await fetchAdapterProjection(page, documentId, mindmapId);
const lineStyle = baseStyleProjection?.compatPayload?.style?.map?.lineStyle;
if (lineStyle !== "curve") {
throw new Error(`command_failed:baseStyle compat lineStyle=${JSON.stringify(lineStyle)}`);
}
await page.getByTestId("mindmap-schema-sidebar-tab-outline").click({ timeout: UI_TIMEOUT_MS });
const outlineItems = await page
.locator('[data-testid="mindmap-schema-sidebar-outline-outline"] li')
.evaluateAll((items) => items.map((item) => item.textContent?.trim()).filter(Boolean));
if (outlineItems.length === 0) {
throw new Error("ui_shell_missing:outline_items");
}
return {
themeOk: themedProjection.theme === "classic4",
layoutOk: layoutProjection.layout === "mindMap",
nodeStyleCompatOk: rootFill === "#dbeafe",
baseStyleCompatOk: lineStyle === "curve",
outlineDerivedOk: outlineItems.length > 0,
outlineItems,
};
}
async function insertMindmapThroughSlash(page) {
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type("/");
const item = page.getByTestId("slash-item-mindmap").first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await item.click({ timeout: UI_TIMEOUT_MS });
}
async function fetchAdapterProjection(page, documentId, mindmapId) {
return page.evaluate(
async ({ documentId, mindmapId }) => {
const response = await fetch(
`/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get`,
{ headers: { Accept: "application/json" } },
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(`projection_load_failed:${response.status}`);
}
if (payload && typeof payload === "object" && !Array.isArray(payload) && payload.result) {
return payload.result;
}
return payload;
},
{ documentId, mindmapId },
);
}
async function editRootNode(page, documentId, mindmapId, text) {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`) &&
response.request().method() === "POST",
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(
async ({ documentId, mindmapId, text }) => {
const response = await fetch(`/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
commandName: "mindmap.command.apply",
documentId,
mindmapId,
commands: [{ type: "updateText", mindmapId, nodeId: "root", text }],
}),
});
if (!response.ok) {
throw new Error(`command_failed:${response.status}`);
}
},
{ documentId, mindmapId, text },
);
const response = await responsePromise;
if (!response.ok()) {
throw new Error(`command_failed:${response.status()}`);
}
const projection = await fetchAdapterProjection(page, documentId, mindmapId);
const root = projection && typeof projection === "object" && projection.root ? projection.root : projection;
const rootText = root?.data?.text;
if (rootText !== text) {
throw new Error(`command_failed:update_text_projection_mismatch text=${JSON.stringify(rootText)}`);
}
}
async function zoomMindmapAndVerifyViewPatch(page, documentId, mindmapId) {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`) &&
response.request().method() === "POST" &&
response.request().postData()?.includes('"type":"patchView"'),
{ timeout: UI_TIMEOUT_MS },
);
await page.getByTestId("mindmap-schema-navigator-action-zoomIn").click({ timeout: UI_TIMEOUT_MS });
const response = await responsePromise;
if (!response.ok()) {
throw new Error(`view_command_failed:${response.status()}`);
}
await page.waitForFunction(
() => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
return scene instanceof HTMLElement && scene.dataset.viewCommandStatus === "success";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const projection = await fetchAdapterProjection(page, documentId, mindmapId);
const scale = projection?.view?.state?.scale;
if (!(typeof scale === "number" && scale > 1)) {
const requestPayload = JSON.parse(response.request().postData() || "null");
throw new Error(
`view_patch_failed scale=${scale} view=${JSON.stringify(projection?.view ?? null)} patch=${JSON.stringify(
requestPayload?.commands?.[0]?.patch ?? null,
)}`,
);
}
return scale;
}
async function centerRootAndVerifyViewPatch(page, documentId, mindmapId) {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`) &&
response.request().method() === "POST" &&
response.request().postData()?.includes('"type":"patchView"'),
{ timeout: UI_TIMEOUT_MS },
);
await page.getByTestId("mindmap-schema-navigator-action-centerRoot").click({ timeout: UI_TIMEOUT_MS });
const response = await responsePromise;
if (!response.ok()) {
throw new Error(`view_command_failed:centerRoot:${response.status()}`);
}
await page.waitForFunction(
() => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
return scene instanceof HTMLElement && scene.dataset.viewCommandStatus === "success";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
return true;
}
async function exerciseNavigatorActions(page, documentId, mindmapId) {
const centerRootOk = await centerRootAndVerifyViewPatch(page, documentId, mindmapId);
const zoomScale = await zoomMindmapAndVerifyViewPatch(page, documentId, mindmapId);
await page.getByTestId("mindmap-schema-navigator-search").fill("KMIND", { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
return scene instanceof HTMLElement && scene.dataset.searchStatus === "ready" && scene.dataset.lastSearchQuery === "KMIND";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.getByTestId("mindmap-schema-navigator-action-minimap").click({ timeout: UI_TIMEOUT_MS });
await page.getByTestId("mindmap-schema-minimap").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return {
centerRootOk,
zoomOk: typeof zoomScale === "number" && zoomScale > 1,
searchOk: true,
minimapOk: true,
zoomScale,
};
}
async function openContextMenu(page, targetKind) {
if (targetKind === "node") {
const node = page.locator('[data-testid="simple-mind-map-runtime"] .smm-node').first();
await node.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await node.click({ timeout: UI_TIMEOUT_MS });
await node.click({ button: "right", timeout: UI_TIMEOUT_MS });
} else {
await page.getByTestId("simple-mind-map-runtime").click({ button: "right", position: { x: 36, y: 36 }, timeout: UI_TIMEOUT_MS });
}
await page.getByTestId("mindmap-schema-context-menu").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const metrics = await readMindmapMetrics(page);
assert(metrics.schemaChrome.contextMenu?.kind === targetKind, `ui_shell_missing:context_menu_kind expected=${targetKind} actual=${metrics.schemaChrome.contextMenu?.kind}`);
return metrics.schemaChrome.contextMenu.actions;
}
async function exerciseContextMenuActions(page, mindmapId) {
const nodeActions = await openContextMenu(page, "node");
for (const actionId of ["insertChild", "insertSiblingAfter", "deleteNode", "summary", "associativeLine", "expandCollapse", "copyNodeText"]) {
assert(nodeActions.includes(actionId), `ui_shell_missing:node_context_action=${actionId}`);
}
await page.getByTestId("mindmap-schema-context-menu-action-copyNodeText").click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
return scene instanceof HTMLElement && scene.dataset.lastContextMenuAction === "copyNodeText" && (scene.dataset.copiedNodeText || "").length > 0;
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const before = await readMindmapSnapshotStats(page, mindmapId);
await openContextMenu(page, "node");
await page.evaluate(() => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
if (scene instanceof HTMLElement) {
scene.dataset.commandStatus = "smoke-waiting";
delete scene.dataset.lastSchemaAction;
}
});
await page.getByTestId("mindmap-schema-context-menu-action-insertChild").click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
return scene instanceof HTMLElement && scene.dataset.lastSchemaAction === "insertChild" && scene.dataset.commandStatus === "success";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const afterInsert = await readMindmapSnapshotStats(page, mindmapId);
const canvasActions = await openContextMenu(page, "canvas");
for (const actionId of ["centerRoot", "fitView", "search", "readonly"]) {
assert(canvasActions.includes(actionId), `ui_shell_missing:canvas_context_action=${actionId}`);
}
assert(!canvasActions.includes("insertChild"), "ui_shell_missing:canvas_context_hides_node_action");
return {
nodeMenuOk: nodeActions.includes("insertChild") && nodeActions.includes("copyNodeText"),
canvasMenuOk: canvasActions.includes("centerRoot") && canvasActions.includes("readonly") && !canvasActions.includes("insertChild"),
copyTextOk: true,
insertChildOk: afterInsert.nodeCount >= before.nodeCount + 1,
beforeNodeCount: before.nodeCount,
afterInsertNodeCount: afterInsert.nodeCount,
};
}
async function waitForMindmapText(page, mindmapId, text, stage) {
await page.waitForFunction(
({ mindmapId, text }) => {
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
const bridge = registry[mindmapId];
const snapshot = bridge && typeof bridge.getSnapshot === "function" ? bridge.getSnapshot() : null;
const root =
snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) && "root" in snapshot
? snapshot.root
: snapshot;
const stack = root ? [root] : [];
while (stack.length > 0) {
const node = stack.pop();
if (!node || typeof node !== "object" || Array.isArray(node)) continue;
if (node.data && node.data.text === text) return true;
if (Array.isArray(node.children)) stack.push(...node.children);
}
return false;
},
{ mindmapId, text },
{ timeout: UI_TIMEOUT_MS },
).catch((error) => {
throw new Error(`reload_mismatch:${stage}:${error.message}`);
});
}
async function main() {
await fs.rm(OUTPUT_DIR, { recursive: true, force: true });
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const portReport = [checkPort(3000), checkPort(8000)];
const screenshots = [];
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
const page = await context.newPage();
const createdIds = [];
let result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
documentId: null,
mindmapId: null,
nodeCount: 0,
edgeCount: 0,
toolbarActions: null,
sidebarActions: null,
kernelRevision: null,
adapterSource: null,
viewScale: null,
navigatorActions: null,
contextMenuActions: null,
screenshots,
ports: portReport,
};
try {
await ensureAuthenticated(page, context.request);
const doc = await createTempDocument(context.request, null);
createdIds.push(doc.documentId);
const title = `task166-mindmap-${Date.now().toString().slice(-6)}`;
await renameDocument(context.request, doc.workspaceId, doc.documentId, title);
result.documentId = doc.documentId;
await openDocument(page, doc.workspaceId, doc.documentId);
await insertMindmapThroughSlash(page);
const inserted = await assertMindmapReady(page, "insert");
screenshots.push(await screenshot(page, "01-after-insert-floating-overlay"));
screenshots.push(await screenshot(page, "toolbar-actions"));
const mindmapId = inserted.mindmapId;
assert(mindmapId, "insert: mindmapId 缺失");
result.mindmapId = mindmapId;
const insertedProjection = await fetchAdapterProjection(page, doc.documentId, mindmapId);
result.kernelRevision =
typeof insertedProjection?.kernelRevision === "number" ? insertedProjection.kernelRevision : null;
result.adapterSource =
typeof insertedProjection?.source === "string"
? insertedProjection.source
: typeof insertedProjection?.compatPayload?.source === "string"
? insertedProjection.compatPayload.source
: null;
const editedText = `KMIND-${Date.now().toString().slice(-5)}`;
await editRootNode(page, doc.documentId, mindmapId, editedText);
const edited = await assertMindmapReady(page, "edit");
screenshots.push(await screenshot(page, "02-after-edit"));
await verifyReadonlyToolbarState(page, "toolbar");
const toolbarActions = await exerciseToolbarNodeActions(page, doc.documentId, mindmapId);
assert(toolbarActions.insertChildOk, "command_failed:insertChild");
assert(toolbarActions.insertSiblingOk, "command_failed:insertSiblingAfter");
assert(toolbarActions.deleteOk, "command_failed:deleteNode");
screenshots.push(await screenshot(page, "03-after-toolbar-actions"));
for (const [panelId, name] of [
["nodeStyle", "sidebar-node-style"],
["baseStyle", "sidebar-base-style"],
["theme", "sidebar-theme"],
["structure", "sidebar-structure"],
["outline", "sidebar-outline"],
]) {
await page.getByTestId(`mindmap-schema-sidebar-tab-${panelId}`).click({ timeout: UI_TIMEOUT_MS });
screenshots.push(await screenshot(page, name));
}
const sidebarActions = await exerciseSidebarActions(page, doc.documentId, mindmapId);
assert(sidebarActions.themeOk, "command_failed:setTheme");
assert(sidebarActions.layoutOk, "command_failed:setLayout");
assert(sidebarActions.nodeStyleCompatOk, "command_failed:nodeStyleCompat");
assert(sidebarActions.baseStyleCompatOk, "command_failed:baseStyleCompat");
assert(sidebarActions.outlineDerivedOk, "ui_shell_missing:outline");
screenshots.push(await screenshot(page, "sidebar-actions"));
screenshots.push(await screenshot(page, "navigator-default"));
const navigatorActions = await exerciseNavigatorActions(page, doc.documentId, mindmapId);
assert(navigatorActions.centerRootOk, "view_command_failed:centerRoot");
assert(navigatorActions.zoomOk, "view_command_failed:zoomIn");
assert(navigatorActions.searchOk, "ui_shell_missing:search");
assert(navigatorActions.minimapOk, "ui_shell_missing:minimap");
screenshots.push(await screenshot(page, "navigator-zoomed"));
screenshots.push(await screenshot(page, "navigator-minimap"));
const contextMenuActions = await exerciseContextMenuActions(page, mindmapId);
assert(contextMenuActions.nodeMenuOk, "ui_shell_missing:node_context_menu");
assert(contextMenuActions.canvasMenuOk, "ui_shell_missing:canvas_context_menu");
assert(contextMenuActions.copyTextOk, "command_failed:copyNodeText");
assert(contextMenuActions.insertChildOk, "command_failed:context_insertChild");
screenshots.push(await screenshot(page, "context-menu-actions"));
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const reloaded = await assertMindmapReady(page, "reload");
await waitForMindmapText(page, mindmapId, editedText, "reload");
const reloadStats = await readMindmapSnapshotStats(page, mindmapId);
const expectedReloadNodeCount = contextMenuActions?.afterInsertNodeCount ?? toolbarActions.afterDeleteNodeCount;
assert(
reloadStats.nodeCount === expectedReloadNodeCount,
`reload_mismatch:toolbar_node_count expected=${expectedReloadNodeCount} actual=${reloadStats.nodeCount}`,
);
const reloadProjection = await fetchAdapterProjection(page, doc.documentId, mindmapId);
assert(reloadProjection?.theme === "classic4", `reload_mismatch:theme actual=${JSON.stringify(reloadProjection?.theme)}`);
assert(reloadProjection?.layout === "mindMap", `reload_mismatch:layout actual=${JSON.stringify(reloadProjection?.layout)}`);
assert(
reloadProjection?.root?.data?.fillColor === "#dbeafe",
`reload_mismatch:node_style actual=${JSON.stringify(reloadProjection?.root?.data)}`,
);
assert(
reloadProjection?.compatPayload?.style?.map?.lineStyle === "curve",
`reload_mismatch:base_style actual=${JSON.stringify(reloadProjection?.compatPayload?.style)}`,
);
screenshots.push(await screenshot(page, "04-after-reload"));
result = {
...result,
ok: true,
nodeCount: reloaded.nodeCount,
edgeCount: reloaded.edgeCount,
toolbarActions,
sidebarActions,
navigatorActions,
contextMenuActions,
kernelRevision: result.kernelRevision,
adapterSource: result.adapterSource,
viewScale: navigatorActions.zoomScale,
};
await writeResult(result);
process.stdout.write(`${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`);
} catch (error) {
const metrics = await readMindmapMetrics(page).catch(() => null);
const failureStage =
metrics?.errorStage ||
(error instanceof Error && /projection_load_failed|adapter_init_failed|runtime_render_failed|ui_shell_missing|command_failed|reload_mismatch/.test(error.message)
? error.message
: "runtime_render_failed");
result = {
...result,
ok: false,
error: error instanceof Error ? error.message : String(error),
failureStage,
nodeCount: metrics?.nodeCount ?? result.nodeCount,
edgeCount: metrics?.edgeCount ?? result.edgeCount,
};
screenshots.push(await screenshot(page, "99-failure").catch(() => null));
await writeResult(result);
throw error;
} finally {
if (createdIds.length > 0) {
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
@@ -54,6 +54,12 @@ async function verifyEntry() {
if (!source.includes("export function unmount(mount_id)")) {
throw new Error("island 入口缺少 unmount 导出");
}
if (!source.includes("export function mount_mindmap_shell(container, options)")) {
throw new Error("island 入口缺少 mount_mindmap_shell 导出");
}
if (!source.includes("export function unmount_mindmap_shell(mount_id)")) {
throw new Error("island 入口缺少 unmount_mindmap_shell 导出");
}
}
async function buildLeptosTiptapIsland() {
@@ -25,6 +25,14 @@ export async function GET(
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
const url = new URL(request.url);
const view = url.searchParams.get("view");
const queryName =
view === "simple_mind_map_scene" || url.searchParams.get("queryName") === "mindmap.simple_mind_map_scene.get"
? "mindmap.simple_mind_map_scene.get"
: view === "editor_scene"
? "mindmap.editor_scene.get"
: "mindmaps.get";
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
@@ -43,11 +51,12 @@ export async function GET(
},
});
const envelope = buildDocumentQueryEnvelope({
name: "mindmaps.get",
name: queryName,
payload: {
documentId: docId,
mindmapId,
workspaceId: null,
rootNodeId: url.searchParams.get("rootNodeId"),
},
});
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
@@ -65,6 +74,9 @@ export async function GET(
client,
plan,
});
if (queryName === "mindmap.simple_mind_map_scene.get" || queryName === "mindmap.editor_scene.get") {
return NextResponse.json(res);
}
return NextResponse.json({
data: res?.data ?? defaultMindmapData,
source: "convex",
@@ -97,9 +109,12 @@ export async function POST(
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
const { data, createOnly, commandName, commands, projectionRevision } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown;
createOnly?: boolean;
commandName?: string;
commands?: unknown[];
projectionRevision?: number | null;
};
try {
@@ -116,22 +131,31 @@ export async function POST(
client: "wolai-frontend",
},
});
const isCommandApply = commandName === "mindmap.command.apply";
const envelope = buildDocumentCommandEnvelope({
name: "mindmaps.put",
payload: {
documentId: docId,
mindmapId,
data: data ?? defaultMindmapData,
createOnly: typeof createOnly === "boolean" ? createOnly : false,
},
name: isCommandApply ? "mindmap.command.apply" : "mindmaps.put",
payload: isCommandApply
? {
documentId: docId,
mindmapId,
workspaceId: null,
commands: Array.isArray(commands) ? commands : [],
projectionRevision: typeof projectionRevision === "number" ? projectionRevision : null,
}
: {
documentId: docId,
mindmapId,
data: data ?? defaultMindmapData,
createOnly: typeof createOnly === "boolean" ? createOnly : false,
},
context,
target: {
workspaceId: null,
pageId: docId,
blockId: mindmapId,
},
reason: "mindmap-route:put",
refs: ["task-032", "mindmap-route"],
reason: isCommandApply ? "mindmap-route:command-apply" : "mindmap-route:put",
refs: isCommandApply ? ["task-166", "mindmap-command-bridge"] : ["task-032", "mindmap-route"],
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
const result = await executeRustBridgeMutationTransport<{
@@ -7,31 +7,41 @@ import {
buildMindmapProjection,
defaultMindmapData,
type MindmapProjection,
type MindmapSimpleMindMapScene,
} from "@/lib/mindmap/mindmap-projection";
import MindmapPageClient from "./mindmap-page-client";
type MindmapRouteQueryResult = {
data?: unknown;
meta?: unknown;
};
export const MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT = {
shellOwner: "mnote-web",
runtimeRole: "legacy_compat_island",
island: "mindmap_runtime",
runtimeRole: "simple_mind_map_adapter",
island: "leptos_mindmap_adapter",
projectionQueryName: "mindmap.simple_mind_map_scene.get",
commandName: "mindmap.command.apply",
} as const;
async function fetchMindmapProjectionOnServer(input: {
async function fetchMindmapAdapterProjectionOnServer(input: {
docId: string;
mindmapId: string;
}): Promise<MindmapProjection | null> {
}): Promise<MindmapSimpleMindMapScene | null> {
if (!isConvexEnabled()) {
return buildMindmapProjection({
return {
schema: "mnote.mindmap.simple_mind_map_scene.v1",
runtime: "simple-mind-map",
documentId: input.docId,
mindmapId: input.mindmapId,
data: defaultMindmapData,
rootNodeId: null,
root: defaultMindmapData,
layout: "logicalStructure",
theme: "classic",
themeConfig: {},
view: {},
config: {},
compatPayload: { source: "compat-blob", mindmapId: input.mindmapId },
kernelRevision: 1,
source: "compat-blob",
owner: "rust-kernel",
meta: null,
});
};
}
try {
@@ -63,7 +73,7 @@ async function fetchMindmapProjectionOnServer(input: {
workspaceId: null,
});
const envelope = buildDocumentQueryEnvelope({
name: "mindmaps.get",
name: MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.projectionQueryName,
payload: {
documentId: input.docId,
mindmapId: input.mindmapId,
@@ -74,43 +84,70 @@ async function fetchMindmapProjectionOnServer(input: {
context,
envelope,
});
const result = await executeRustBridgeQueryTransport<MindmapRouteQueryResult | null>({
const result = await executeRustBridgeQueryTransport<MindmapSimpleMindMapScene | null>({
client,
plan,
});
return buildMindmapProjection({
documentId: input.docId,
mindmapId: input.mindmapId,
data: result?.data ?? defaultMindmapData,
meta: result?.meta ?? {
requestId: context.requestId,
traceId: context.traceId,
workspaceId: context.workspaceId,
documentId: input.docId,
pageId: input.docId,
mindmapId: input.mindmapId,
attachmentId: input.mindmapId,
},
});
return result
? {
...result,
source: result.source ?? "compat-blob",
owner: result.owner ?? "rust-kernel",
meta: result.meta ?? {
requestId: context.requestId,
traceId: context.traceId,
workspaceId: context.workspaceId,
documentId: input.docId,
pageId: input.docId,
mindmapId: input.mindmapId,
attachmentId: input.mindmapId,
},
}
: null;
} catch {
return null;
}
}
function buildStandaloneProjectionFromAdapter(input: {
docId: string;
mindmapId: string;
adapterProjection: MindmapSimpleMindMapScene | null;
}): MindmapProjection | null {
if (!input.adapterProjection) return null;
return buildMindmapProjection({
documentId: input.docId,
mindmapId: input.mindmapId,
data: input.adapterProjection.root ?? defaultMindmapData,
source: input.adapterProjection.source ?? "compat-blob",
owner: input.adapterProjection.owner ?? "rust-kernel",
meta: input.adapterProjection.meta,
});
}
export default async function MindmapFullscreenPage({
params,
}: {
params: Promise<{ docId: string; mindmapId: string }>;
}) {
const { docId, mindmapId } = await params;
const initialProjection = await fetchMindmapProjectionOnServer({ docId, mindmapId });
const initialAdapterProjection = await fetchMindmapAdapterProjectionOnServer({ docId, mindmapId });
const initialProjection = buildStandaloneProjectionFromAdapter({
docId,
mindmapId,
adapterProjection: initialAdapterProjection,
});
return (
<main
data-react-island={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.island}
data-leptos-mindmap-island={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.island}
data-runtime-role={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.runtimeRole}
data-shell-owner={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.shellOwner}
data-projection-query={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.projectionQueryName}
data-command-name={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.commandName}
data-adapter-source={initialAdapterProjection?.source ?? "compat-blob"}
data-kernel-revision={initialAdapterProjection?.kernelRevision ?? 1}
>
<MindmapPageClient
docId={docId}
@@ -1,6 +1,9 @@
"use client";
import "simple-mind-map/dist/simpleMindMap.esm.css";
"use client";
// 说明:本文件仅保留为 legacy/compat/reference 实现,用于独立导图页、
// 历史 BlockNote 导图块和 blob import/export 对照;3000 文档页 Phase 6
// 默认主链是 leptos-tiptap NodeView + leptos-mindmap adapter。
import "simple-mind-map/dist/simpleMindMap.esm.css";
import React, {
useCallback,
useEffect,
@@ -1,7 +1,10 @@
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import { createPortal } from "react-dom";
import type { MindMapNode } from "./mindmapTypes";
// 菜单项配置
@@ -1,6 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useState } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
type CountProps = {
mindmap: any;
};
@@ -3,6 +3,9 @@
/* eslint-disable @next/next/no-img-element */
import React, { useEffect, useRef, useState } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
type MiniMapProps = {
mindmap: any;
show: boolean;
@@ -1,5 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import { Minus, Plus, Maximize, Map, MapPin, Minimize, Eye, EyeOff } from "lucide-react";
type NavigatorProps = {
@@ -1,6 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
import Image from "next/image";
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import Image from "next/image";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Toggle } from "@/components/ui/toggle";
@@ -1,4 +1,7 @@
import React from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import { ChevronRight } from "lucide-react";
import { sidebarTriggers, type SidebarPanel } from "./mindmapSidebarConfig";
@@ -1,5 +1,8 @@
import React from "react";
import {
import React from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import {
fileToolbarMeta,
fileToolbarOrder,
nodeToolbarMeta,
@@ -5,6 +5,12 @@ import {
} from "@/components/editor/editor-host-config";
import { describe, expect, it } from "vitest";
const phase6MindmapSuccessSelectors = [
'[data-testid="mnote-mindmap-editor-root"]',
'[data-testid="simple-mind-map-runtime"]',
'[data-testid="mindmap-rust-shell"]',
];
describe("editor-host-config", () => {
it("保持 island 为默认正式 host", () => {
expect(DEFAULT_EDITOR_HOST_KIND).toBe("leptos_tiptap_island");
@@ -16,22 +22,34 @@ describe("editor-host-config", () => {
expect(normalizeEditorHostKind("leptos_tiptap")).toBe("leptos_tiptap_island");
});
it("保留 iframe debug 作为显式调试 host", () => {
expect(normalizeEditorHostKind("leptos_tiptap_iframe_debug")).toBe(
"leptos_tiptap_iframe_debug",
);
expect(normalizeEditorHostKind("iframe_debug")).toBe("leptos_tiptap_iframe_debug");
expect(normalizeEditorHostKind("leptos_tiptap_debug")).toBe(
"leptos_tiptap_iframe_debug",
);
});
it("优先尊重 query override 的 debug host 选择", () => {
it(" iframe debug host 配置应折返到正式 island host", () => {
expect(normalizeEditorHostKind("leptos_tiptap_iframe_debug")).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("iframe_debug")).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("leptos_tiptap_debug")).toBe("leptos_tiptap_island");
expect(
resolveEditorHostKind({
override: "leptos_tiptap_iframe_debug",
runtimeDefault: "leptos_tiptap_island",
}),
).toBe("leptos_tiptap_iframe_debug");
).toBe("leptos_tiptap_island");
});
it("不再允许通过 query 或 runtime 默认值切回 blocknote", () => {
expect(
resolveEditorHostKind({
override: "blocknote",
runtimeDefault: "leptos_tiptap_island",
}),
).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("blocknote")).toBe("leptos_tiptap_island");
});
it("Phase 6 mindmap 成功条件不应依赖旧 React runtime 标记", () => {
expect(phase6MindmapSuccessSelectors).not.toContain(
".mnote-mindmap-react-mount",
);
expect(phase6MindmapSuccessSelectors).not.toContain(
"window.__mindmapInstance",
);
});
});
@@ -1,7 +1,4 @@
export type EditorHostKind =
| "blocknote"
| "leptos_tiptap_island"
| "leptos_tiptap_iframe_debug";
export type EditorHostKind = "leptos_tiptap_island";
export interface EditorHostConfig {
kind: EditorHostKind;
@@ -15,7 +12,7 @@ export function normalizeEditorHostKind(
): EditorHostKind {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "blocknote") {
return "blocknote";
return fallback;
}
if (
normalized === "leptos_tiptap_runtime" ||
@@ -24,15 +21,7 @@ export function normalizeEditorHostKind(
) {
return "leptos_tiptap_island";
}
// 说明:iframe 只保留给显式调试桥,不再混入正式 runtime 主链
if (
normalized === "leptos_tiptap_iframe_debug" ||
normalized === "leptos_tiptap_debug" ||
normalized === "iframe_debug"
) {
return "leptos_tiptap_iframe_debug";
}
// 说明:保留历史 query 值兼容;未显式声明 debug 时,一律回到正式 runtime host。
// 说明:历史 debug host 已退出文档页主路径,旧 query/runtime 值统一折返正式 island host
return fallback;
}
@@ -53,7 +42,7 @@ export function resolveEditorHostKind(input: {
}
export function isLeptosTiptapHostKind(kind: EditorHostKind): boolean {
return kind !== "blocknote";
return kind === "leptos_tiptap_island";
}
export function getEditorHostKindFromEnv(value?: unknown): EditorHostKind {
@@ -17,19 +17,6 @@ const LeptosTiptapIslandEditor = dynamic(
},
) as ComponentType<DocumentEditorHostProps>;
const LeptosTiptapIframeDebugEditor = dynamic(
() => import("@/components/editor/leptos-tiptap-editor-host").then((mod) => mod.LeptosTiptapEditorHost),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
export function EditorHost(props: DocumentEditorHostProps) {
if (props.hostKind === "leptos_tiptap_iframe_debug") {
return <LeptosTiptapIframeDebugEditor {...props} />;
}
return <LeptosTiptapIslandEditor {...props} />;
}
@@ -1,6 +1,7 @@
import { act, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { readFileSync } from "node:fs";
import { LeptosTiptapIslandEditorHost } from "@/components/editor/leptos-tiptap-island-editor-host";
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
@@ -135,6 +136,18 @@ describe("leptos-tiptap-island-editor-host", () => {
let container: HTMLDivElement;
let root: Root;
it("默认 island host 不加载旧 React mindmap 主链", () => {
const source = readFileSync("src/components/editor/leptos-tiptap-island-editor-host.tsx", "utf8");
const smokeSource = readFileSync("../scripts/task166-mindmap-phase6-block-smoke.js", "utf8");
expect(source).not.toContain("MindmapBlockView");
expect(source).not.toContain("mnote-mindmap-react-mount");
expect(source).not.toContain("mnoteMindmapData");
expect(source).not.toContain("__mindmapInstance");
expect(smokeSource).not.toContain("mnote-mindmap-react-mount");
expect(smokeSource).not.toContain("__mindmapInstance");
});
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
@@ -39,8 +39,21 @@ type IslandRuntimeModule = {
default: (input?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module) => Promise<unknown>;
mount: (container: Element, options: unknown) => number;
unmount: (mountId: number) => void;
mount_mindmap_shell?: (container: Element, options: unknown) => number;
unmount_mindmap_shell?: (mountId: number) => void;
};
type MindmapRustShellBridge = {
mount: (container: Element, options: unknown) => number;
unmount: (mountId: number) => void;
};
declare global {
interface Window {
__MNOTE_MINDMAP_RUST_SHELL__?: MindmapRustShellBridge;
}
}
type RuntimeEnvelope<T = unknown> = {
protocol?: string;
runtime?: string;
@@ -251,6 +264,15 @@ async function loadIslandRuntime(): Promise<{
throw new Error("island entry 缺少 unmount 导出");
}
await runtimeModule.default(wasmAssetUrl ?? undefined);
if (
typeof runtimeModule.mount_mindmap_shell === "function" &&
typeof runtimeModule.unmount_mindmap_shell === "function"
) {
window.__MNOTE_MINDMAP_RUST_SHELL__ = {
mount: runtimeModule.mount_mindmap_shell,
unmount: runtimeModule.unmount_mindmap_shell,
};
}
return {
runtimeModule,
entryAssetUrl,
@@ -778,9 +800,6 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
);
}
},
requestFallbackToBlockNote: () => {
requestFallback("explicit_fallback", "通过页面壳显式切回 BlockNote");
},
};
useEditorBridgeStore.getState().registerBridge(editorBridge);
return () => {
@@ -219,4 +219,54 @@ describe("tiptap-content-converter", () => {
]);
});
it("保留 slash 插入的 mindmap placeholder 骨架为 mindmap legacy block", () => {
const tiptapDoc = {
type: "doc" as const,
content: [
{
type: "paragraph",
attrs: {
blockId: "mind_1",
mnoteBlockType: "mindmap",
mnoteMindmapData: {
data: { uid: "root", text: "KMIND", generalization: { text: "概要" } },
children: [
{
data: { uid: "topic", text: "二级节点" },
children: [
{ data: { uid: "branch-1", text: "分支主题" }, children: [] },
{ data: { uid: "branch-2", text: "分支主题" }, children: [] },
],
},
],
},
},
content: [{ type: "text", text: "KMIND / 二级节点 / 分支主题 / 分支主题 / 概要" }],
},
],
};
expect(blocksFromTiptapDoc(tiptapDoc)).toEqual([
{
id: "mind_1",
type: "mindmap",
props: {
data: {
data: { uid: "root", text: "KMIND", generalization: { text: "概要" } },
children: [
{
data: { uid: "topic", text: "二级节点" },
children: [
{ data: { uid: "branch-1", text: "分支主题" }, children: [] },
{ data: { uid: "branch-2", text: "分支主题" }, children: [] },
],
},
],
},
},
content: "",
},
]);
});
});
@@ -31,7 +31,8 @@ export type EditorBlockType =
| "numbered_list_item"
| "todo"
| "quote"
| "code_block";
| "code_block"
| "mindmap";
export type EditorContentNode = {
type: "text";
@@ -46,6 +47,7 @@ export type EditorBlock = {
headingLevel?: number | null;
checked?: boolean | null;
language?: string | null;
data?: unknown;
};
contentNodes?: EditorContentNode[];
childBlockIds?: string[];
@@ -246,6 +248,15 @@ function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBloc
contentNodes,
childBlockIds: [],
};
case "mindmap": {
return {
blockId,
blockType: "mindmap",
props: { data: props.data ?? block.content },
contentNodes: [],
childBlockIds: [],
};
}
case "media": {
const sourcePath = firstNonEmptyText(props.sourcePath, props.url, props.src);
const name = firstNonEmptyText(props.name, props.fileName, props.title, sourcePath);
@@ -361,6 +372,16 @@ function blockToTiptapNode(block: EditorBlock): TiptapNode {
attrs: { ...commonAttrs, language: block.props?.language ?? null },
content: textNodesToInline(block.contentNodes),
};
case "mindmap":
return {
type: "paragraph",
attrs: {
...commonAttrs,
mnoteBlockType: "mindmap",
mnoteMindmapData: block.props?.data ?? null,
},
content: textNodesToInline(block.contentNodes),
};
case "paragraph":
default:
return {
@@ -408,6 +429,15 @@ function nodeBlockId(node: TiptapNode, index: number): string {
function tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null {
const blockId = nodeBlockId(node, index);
if (node.attrs?.mnoteBlockType === "mindmap") {
return {
blockId,
blockType: "mindmap",
props: { data: node.attrs.mnoteMindmapData },
contentNodes: [],
childBlockIds: [],
};
}
switch (node.type) {
case "paragraph":
return { blockId, blockType: "paragraph", props: {}, contentNodes: extractInlineContent(node), childBlockIds: [] };
@@ -592,8 +622,10 @@ export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocumen
? { checked: Boolean(block.props?.checked) }
: block.blockType === "code_block"
? { language: block.props?.language ?? null }
: block.blockType === "mindmap"
? { data: block.props?.data ?? null }
: undefined,
content: legacyContentFromNodes(block.contentNodes),
content: block.blockType === "mindmap" ? "" : legacyContentFromNodes(block.contentNodes),
}));
}
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import {
createMindmapAdapterProjectionEndpoint,
createMindmapCommandApplyEndpoint,
executeMindmapCommandApply,
executeMindmapCommandApplyAndRefreshProjection,
isMindmapAdapterProjection,
requestMindmapAdapterProjection,
} from "./leptos-mindmap-adapter";
import { createFallbackMindmapAdapterProjection } from "./simple-mind-map-bridge";
describe("leptos-mindmap adapter projection", () => {
it("识别 adapter projection contract", () => {
const projection = createFallbackMindmapAdapterProjection({ mindmapId: "mind_1" });
expect(isMindmapAdapterProjection(projection)).toBe(true);
expect(isMindmapAdapterProjection({ ...projection, runtime: "other" })).toBe(false);
});
it("构建 simple_mind_map_scene 查询 endpoint", () => {
expect(createMindmapAdapterProjectionEndpoint("doc 1", "mind/1")).toBe(
"/api/mindmap/doc%201/mind%2F1?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get",
);
expect(createMindmapCommandApplyEndpoint("doc 1", "mind/1")).toBe(
"/api/mindmap/doc%201/mind%2F1",
);
});
it("从 result wrapper 中读取 projection", async () => {
const projection = createFallbackMindmapAdapterProjection({ mindmapId: "mind_1" });
const fetcher = vi.fn(async () => ({
ok: true,
json: async () => ({ result: projection }),
})) as unknown as typeof fetch;
await expect(
requestMindmapAdapterProjection({
documentId: "doc_1",
mindmapId: "mind_1",
fetcher,
}),
).resolves.toEqual(projection);
});
it("command apply 成功后可刷新 adapter projection", async () => {
const projection = createFallbackMindmapAdapterProjection({ mindmapId: "mind_1", kernelRevision: 12 });
const fetcher = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ ok: true, kernelRevision: 11 }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: projection }),
}) as unknown as typeof fetch;
await expect(
executeMindmapCommandApplyAndRefreshProjection({
documentId: "doc_1",
mindmapId: "mind_1",
commands: [{ type: "updateText", mindmapId: "mind_1", nodeId: "root", text: "新标题" }],
projectionRevision: 10,
fetcher,
}),
).resolves.toEqual(projection);
expect(fetcher).toHaveBeenNthCalledWith(
1,
"/api/mindmap/doc_1/mind_1",
expect.objectContaining({
method: "POST",
body: expect.stringContaining('"commandName":"mindmap.command.apply"'),
}),
);
});
it("command apply 失败时抛出可定位 command_failed 错误", async () => {
const fetcher = vi.fn(async () => ({
ok: false,
status: 409,
json: async () => ({ error: "revision_conflict" }),
})) as unknown as typeof fetch;
await expect(
executeMindmapCommandApply({
documentId: "doc_1",
mindmapId: "mind_1",
commands: [{ type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" }],
fetcher,
}),
).rejects.toMatchObject({ code: "command_failed", status: 409 });
});
});
@@ -0,0 +1,169 @@
import {
createSimpleMindMapBridge,
type MindmapAdapterProjection,
type SimpleMindMapBridge,
type SimpleMindMapBridgeEvent,
type SimpleMindMapPluginName,
} from "./simple-mind-map-bridge";
import type { MindmapCompatPayloadPatch, MindmapKernelCommand } from "./mindmap-command-diff";
export type {
MindmapAdapterProjection,
SimpleMindMapBridge,
SimpleMindMapBridgeEvent,
} from "./simple-mind-map-bridge";
export type LeptosMindmapAdapterOptions = {
el: HTMLElement;
projection: MindmapAdapterProjection;
mode?: "edit" | "readonly";
pluginNames?: SimpleMindMapPluginName[];
runtimeOptions?: Record<string, unknown>;
onEvent?: (event: SimpleMindMapBridgeEvent) => void;
};
export type RequestMindmapAdapterProjectionInput = {
documentId: string;
mindmapId: string;
endpoint?: string;
fetcher?: typeof fetch;
};
export type MindmapCommandApplyInput = {
documentId: string;
mindmapId: string;
commands: Array<MindmapKernelCommand | MindmapCompatPayloadPatch>;
projectionRevision?: number | null;
endpoint?: string;
fetcher?: typeof fetch;
};
export type MindmapCommandApplyResult = {
ok: true;
kernelRevision: number | null;
raw: unknown;
};
export class MindmapCommandBridgeError extends Error {
readonly code: "command_failed";
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "MindmapCommandBridgeError";
this.code = "command_failed";
this.status = status;
}
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const isMindmapAdapterProjection = (value: unknown): value is MindmapAdapterProjection => {
if (!isRecord(value)) return false;
return (
value.schema === "mnote.mindmap.simple_mind_map_scene.v1" &&
value.runtime === "simple-mind-map" &&
"root" in value &&
typeof value.kernelRevision === "number"
);
};
export const createMindmapAdapterProjectionEndpoint = (documentId: string, mindmapId: string) => {
const doc = encodeURIComponent(documentId);
const map = encodeURIComponent(mindmapId);
return `/api/mindmap/${doc}/${map}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get`;
};
export const createMindmapCommandApplyEndpoint = (documentId: string, mindmapId: string) => {
const doc = encodeURIComponent(documentId);
const map = encodeURIComponent(mindmapId);
return `/api/mindmap/${doc}/${map}`;
};
export const requestMindmapAdapterProjection = async (
input: RequestMindmapAdapterProjectionInput,
): Promise<MindmapAdapterProjection> => {
const fetcher = input.fetcher ?? fetch;
const endpoint = input.endpoint ?? createMindmapAdapterProjectionEndpoint(input.documentId, input.mindmapId);
const response = await fetcher(endpoint, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`projection_load_failed:${response.status}`);
}
const payload = (await response.json()) as unknown;
const projection = isRecord(payload) && "result" in payload ? payload.result : payload;
if (!isMindmapAdapterProjection(projection)) {
throw new Error("projection_load_failed:invalid_adapter_projection");
}
return projection;
};
const readKernelRevision = (value: unknown): number | null => {
if (!isRecord(value)) return null;
const candidates = [
value.kernelRevision,
value.projectionRevision,
isRecord(value.result) ? value.result.kernelRevision : null,
isRecord(value.result) ? value.result.projectionRevision : null,
];
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
}
return null;
};
export const executeMindmapCommandApply = async (
input: MindmapCommandApplyInput,
): Promise<MindmapCommandApplyResult> => {
if (input.commands.length === 0) {
throw new MindmapCommandBridgeError("command_failed:empty_commands");
}
const fetcher = input.fetcher ?? fetch;
const endpoint = input.endpoint ?? createMindmapCommandApplyEndpoint(input.documentId, input.mindmapId);
const response = await fetcher(endpoint, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
commandName: "mindmap.command.apply",
documentId: input.documentId,
mindmapId: input.mindmapId,
commands: input.commands,
projectionRevision: input.projectionRevision ?? null,
}),
});
const raw = (await response.json().catch(() => null)) as unknown;
if (!response.ok) {
throw new MindmapCommandBridgeError(`command_failed:${response.status}`, response.status);
}
return {
ok: true,
kernelRevision: readKernelRevision(raw),
raw,
};
};
export const executeMindmapCommandApplyAndRefreshProjection = async (
input: MindmapCommandApplyInput & { projectionEndpoint?: string },
): Promise<MindmapAdapterProjection> => {
await executeMindmapCommandApply(input);
return requestMindmapAdapterProjection({
documentId: input.documentId,
mindmapId: input.mindmapId,
endpoint: input.projectionEndpoint,
fetcher: input.fetcher,
});
};
export const createLeptosMindmapAdapter = async (
input: LeptosMindmapAdapterOptions,
): Promise<SimpleMindMapBridge> => {
if (!isMindmapAdapterProjection(input.projection)) {
throw new Error("adapter_init_failed:invalid_projection");
}
return createSimpleMindMapBridge(input);
};
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
getMindmapActionMapping,
listMindmapActionMappings,
mapMindmapActionToCommand,
} from "./mindmap-action-map";
describe("mindmap action map", () => {
it("把核心节点动作映射到 runtime command 和 kernel command", () => {
expect(mapMindmapActionToCommand({ actionId: "insertChild", mindmapId: "mind_1", activeNodeId: "node_1" })).toEqual({
runtimeCommand: "INSERT_CHILD_NODE",
command: {
type: "insertChild",
mindmapId: "mind_1",
parentNodeId: "node_1",
node: { text: "新节点" },
},
});
expect(
mapMindmapActionToCommand({ actionId: "insertSiblingAfter", mindmapId: "mind_1", activeNodeId: "node_1" }),
).toEqual({
runtimeCommand: "INSERT_NODE",
command: {
type: "insertSiblingAfter",
mindmapId: "mind_1",
targetNodeId: "node_1",
node: { text: "新节点" },
},
});
expect(mapMindmapActionToCommand({ actionId: "deleteNode", mindmapId: "mind_1", activeNodeId: "node_1" })).toEqual({
runtimeCommand: "DELETE_NODE",
command: { type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" },
});
});
it("把视图动作映射到 localView,不依赖 active node", () => {
expect(getMindmapActionMapping("centerRoot")).toMatchObject({ target: "localView", runtimeMethod: "centerRoot" });
expect(getMindmapActionMapping("zoomIn")).toMatchObject({ target: "localView", runtimeMethod: "zoomIn" });
expect(getMindmapActionMapping("zoomOut")).toMatchObject({ target: "localView", runtimeMethod: "zoomOut" });
expect(getMindmapActionMapping("fitView")).toMatchObject({ target: "localView", runtimeMethod: "fitView" });
});
it("把主题、结构和扩展字段映射到 kernel/compat", () => {
expect(mapMindmapActionToCommand({ actionId: "setTheme", mindmapId: "mind_1", value: "classic4" })).toEqual({
command: { type: "setTheme", mindmapId: "mind_1", theme: "classic4" },
});
expect(mapMindmapActionToCommand({ actionId: "setLayout", mindmapId: "mind_1", value: "logicalStructure" })).toEqual({
command: { type: "setLayout", mindmapId: "mind_1", layout: "logicalStructure" },
});
expect(mapMindmapActionToCommand({ actionId: "note", mindmapId: "mind_1", activeNodeId: "node_1", value: "备注" })).toEqual({
command: {
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "nodes.node_1.data.note",
value: "备注",
source: "toolbar",
actionId: "note",
},
});
expect(
mapMindmapActionToCommand({
actionId: "painter",
mindmapId: "mind_1",
activeNodeId: "node_1",
value: { fillColor: "#dbeafe" },
source: "sidebar",
}),
).toEqual({
command: {
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "nodes.node_1.data.style",
value: { fillColor: "#dbeafe" },
source: "sidebar",
actionId: "painter",
},
});
});
it("所有 action id 映射唯一", () => {
const mappings = listMindmapActionMappings();
const ids = mappings.map((mapping) => mapping.actionId);
expect(new Set(ids).size).toBe(ids.length);
});
});
@@ -0,0 +1,141 @@
import {
createCompatPayloadPatch,
createLayoutCommand,
createThemeCommand,
createToolbarKernelCommand,
type MindmapCompatPayloadPatch,
type MindmapKernelCommand,
type MindmapNodeInput,
} from "./mindmap-command-diff";
import type { MindmapUiActionId } from "./mindmap-ui-schema";
export type MindmapActionTarget = "runtimeCommand" | "kernelCommand" | "compatPatch" | "localView";
export type MindmapRuntimeViewMethod = "centerRoot" | "zoomIn" | "zoomOut" | "fitView";
export type MindmapActionMapping = {
actionId: MindmapUiActionId;
target: MindmapActionTarget;
runtimeCommand?: string;
runtimeMethod?: MindmapRuntimeViewMethod;
requiresActiveNode: boolean;
readonlyAllowed: boolean;
compatPath?: (activeNodeId: string | null) => string | null;
};
export type MindmapActionCommandInput = {
actionId: MindmapUiActionId;
mindmapId: string;
activeNodeId?: string | null;
value?: unknown;
node?: MindmapNodeInput;
themeConfig?: unknown;
source?: MindmapCompatPayloadPatch["source"];
runtimeRevision?: number | null;
kernelRevision?: number | null;
};
export type MindmapActionCommandResult = {
runtimeCommand?: string;
command?: MindmapKernelCommand | MindmapCompatPayloadPatch;
};
const nodeDataPath = (field: string) => (activeNodeId: string | null): string | null =>
activeNodeId ? `nodes.${activeNodeId}.data.${field}` : null;
const mappings: MindmapActionMapping[] = [
{ actionId: "undo", target: "runtimeCommand", runtimeCommand: "BACK", requiresActiveNode: false, readonlyAllowed: false },
{ actionId: "redo", target: "runtimeCommand", runtimeCommand: "FORWARD", requiresActiveNode: false, readonlyAllowed: false },
{ actionId: "editNode", target: "kernelCommand", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "insertSiblingAfter", target: "runtimeCommand", runtimeCommand: "INSERT_NODE", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "insertChild", target: "runtimeCommand", runtimeCommand: "INSERT_CHILD_NODE", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "deleteNode", target: "runtimeCommand", runtimeCommand: "DELETE_NODE", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "summary", target: "compatPatch", runtimeCommand: "ADD_GENERALIZATION", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "associativeLine", target: "compatPatch", runtimeCommand: "ADD_ASSOCIATIVE_LINE", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "setTheme", target: "kernelCommand", requiresActiveNode: false, readonlyAllowed: false },
{ actionId: "setLayout", target: "kernelCommand", requiresActiveNode: false, readonlyAllowed: false },
{ actionId: "tag", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("tag") },
{ actionId: "hyperlink", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("hyperlink") },
{ actionId: "note", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("note") },
{ actionId: "image", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("image") },
{ actionId: "icon", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("icon") },
{ actionId: "formula", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("formula") },
{ actionId: "painter", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("style") },
{ actionId: "import", target: "compatPatch", requiresActiveNode: false, readonlyAllowed: false, compatPath: () => "import" },
{ actionId: "export", target: "runtimeCommand", runtimeCommand: "EXPORT", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "centerRoot", target: "localView", runtimeMethod: "centerRoot", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "zoomIn", target: "localView", runtimeMethod: "zoomIn", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "zoomOut", target: "localView", runtimeMethod: "zoomOut", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "fitView", target: "localView", runtimeMethod: "fitView", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "search", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "expandCollapse", target: "localView", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "copyNodeText", target: "localView", requiresActiveNode: true, readonlyAllowed: true },
{ actionId: "readonly", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
];
export const listMindmapActionMappings = (): MindmapActionMapping[] => mappings;
export const getMindmapActionMapping = (actionId: MindmapUiActionId): MindmapActionMapping | null =>
mappings.find((mapping) => mapping.actionId === actionId) ?? null;
const readActiveNodeId = (value: string | null | undefined): string | null => {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
};
export const mapMindmapActionToCommand = (input: MindmapActionCommandInput): MindmapActionCommandResult | null => {
const mapping = getMindmapActionMapping(input.actionId);
if (!mapping) return null;
const activeNodeId = readActiveNodeId(input.activeNodeId);
if (mapping.requiresActiveNode && !activeNodeId) return null;
if (input.actionId === "setTheme") {
return { command: createThemeCommand(input.mindmapId, input.value, input.themeConfig) };
}
if (input.actionId === "setLayout") {
return { command: createLayoutCommand(input.mindmapId, input.value) };
}
if (input.actionId === "editNode") {
return {
command: {
type: "updateText",
mindmapId: input.mindmapId,
nodeId: activeNodeId ?? "root",
text: typeof input.value === "string" ? input.value : "",
},
};
}
if (mapping.runtimeCommand && ["INSERT_CHILD_NODE", "INSERT_NODE", "DELETE_NODE", "REMOVE_NODE"].includes(mapping.runtimeCommand)) {
const command = createToolbarKernelCommand({
mindmapId: input.mindmapId,
runtimeCommand: mapping.runtimeCommand,
activeNodeId,
newNode: input.node,
});
return command ? { runtimeCommand: mapping.runtimeCommand, command } : { runtimeCommand: mapping.runtimeCommand };
}
if (mapping.compatPath) {
const path = mapping.compatPath(activeNodeId);
if (!path) return null;
return {
command: createCompatPayloadPatch({
mindmapId: input.mindmapId,
path,
value: input.value ?? true,
source: input.source ?? "toolbar",
actionId: input.actionId,
runtimeRevision: input.runtimeRevision,
kernelRevision: input.kernelRevision,
}),
};
}
if (mapping.runtimeCommand) {
return { runtimeCommand: mapping.runtimeCommand };
}
return {};
};
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
createCompatPayloadPatch,
createLayoutCommand,
createThemeCommand,
createToolbarKernelCommand,
createViewPatchCommand,
diffMindmapRuntimeDataToKernelCommands,
} from "./mindmap-command-diff";
describe("mindmap command bridge mapping", () => {
it("把明确 toolbar 动作映射到 kernel command", () => {
expect(
createToolbarKernelCommand({
mindmapId: "mind_1",
runtimeCommand: "INSERT_CHILD_NODE",
activeNodeId: "node_1",
}),
).toEqual({
type: "insertChild",
mindmapId: "mind_1",
parentNodeId: "node_1",
node: { text: "新节点" },
});
expect(
createToolbarKernelCommand({
mindmapId: "mind_1",
runtimeCommand: "REMOVE_NODE",
activeNodeId: "node_2",
}),
).toEqual({
type: "deleteNode",
mindmapId: "mind_1",
nodeId: "node_2",
});
});
it("把暂未语义化的 toolbar 动作落入 compatPayload patch", () => {
expect(
createToolbarKernelCommand({
mindmapId: "mind_1",
runtimeCommand: "ADD_GENERALIZATION",
activeNodeId: "node_1",
}),
).toEqual({
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "root.data.generalization",
value: { text: "概要" },
source: "toolbar",
});
});
it("构建 view/theme/layout 和通用 compat patch command", () => {
expect(createViewPatchCommand("mind_1", { scale: 0.9 })).toEqual({
type: "patchView",
mindmapId: "mind_1",
patch: { scale: 0.9 },
});
expect(createThemeCommand("mind_1", "classic4", { root: true })).toEqual({
type: "setTheme",
mindmapId: "mind_1",
theme: "classic4",
themeConfig: { root: true },
});
expect(createLayoutCommand("mind_1", "logicalStructure")).toEqual({
type: "setLayout",
mindmapId: "mind_1",
layout: "logicalStructure",
});
expect(
createCompatPayloadPatch({
mindmapId: "mind_1",
path: "root.data.image",
value: "asset:img_1",
source: "sidebar",
actionId: "image",
runtimeRevision: 7,
kernelRevision: 3,
}),
).toEqual({
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "root.data.image",
value: "asset:img_1",
source: "sidebar",
actionId: "image",
runtimeRevision: 7,
kernelRevision: 3,
});
});
it("从 data_change diff 识别文本、插入、删除、移动和 compat 字段", () => {
const previous = {
data: { uid: "root", text: "KMIND" },
children: [
{ data: { uid: "a", text: "A", image: "asset:old" }, children: [] },
{ data: { uid: "b", text: "B" }, children: [] },
],
};
const next = {
data: { uid: "root", text: "KMIND" },
children: [
{
data: { uid: "a", text: "A2", image: "asset:new" },
children: [{ data: { uid: "c", text: "C" }, children: [] }],
},
],
};
expect(
diffMindmapRuntimeDataToKernelCommands({
mindmapId: "mind_1",
previous,
next,
}),
).toEqual({
commands: [
{ type: "updateText", mindmapId: "mind_1", nodeId: "a", text: "A2" },
{
type: "insertChild",
mindmapId: "mind_1",
parentNodeId: "a",
node: { uid: "c", text: "C" },
},
{ type: "deleteNode", mindmapId: "mind_1", nodeId: "b" },
],
compatPatches: [
{
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "nodes.a.data.image",
value: "asset:new",
source: "adapter-diff",
},
],
});
});
it("从 data_change diff 识别节点移动", () => {
const previous = {
data: { uid: "root", text: "KMIND" },
children: [
{ data: { uid: "a", text: "A" }, children: [] },
{ data: { uid: "b", text: "B" }, children: [] },
],
};
const next = {
data: { uid: "root", text: "KMIND" },
children: [{ data: { uid: "a", text: "A" }, children: [{ data: { uid: "b", text: "B" }, children: [] }] }],
};
expect(
diffMindmapRuntimeDataToKernelCommands({
mindmapId: "mind_1",
previous,
next,
}).commands,
).toContainEqual({
type: "moveNode",
mindmapId: "mind_1",
nodeId: "b",
newParentNodeId: "a",
order: 0,
});
});
});
@@ -0,0 +1,292 @@
import { canonicalizeMindmapData, type MindMapData } from "./mindmap-projection";
export type MindmapNodeInput = {
uid?: string;
text: string;
hyperlink?: string;
note?: string;
refs?: unknown[];
};
export type MindmapKernelCommand =
| { type: "updateText"; mindmapId: string; nodeId: string; text: string }
| { type: "insertChild"; mindmapId: string; parentNodeId: string; node: MindmapNodeInput }
| { type: "insertSiblingAfter"; mindmapId: string; targetNodeId: string; node: MindmapNodeInput }
| { type: "deleteNode"; mindmapId: string; nodeId: string }
| { type: "moveNode"; mindmapId: string; nodeId: string; newParentNodeId: string; order?: number }
| { type: "patchView"; mindmapId: string; patch: Record<string, unknown> }
| { type: "setLayout"; mindmapId: string; layout: unknown }
| { type: "setTheme"; mindmapId: string; theme: unknown; themeConfig?: unknown };
export type MindmapCompatPayloadPatch = {
type: "compatPayloadPatch";
mindmapId: string;
path: string;
value: unknown;
source: "toolbar" | "sidebar" | "runtime-event" | "adapter-diff";
actionId?: string;
runtimeRevision?: number | null;
kernelRevision?: number | null;
};
export type MindmapCommandDiffResult = {
commands: MindmapKernelCommand[];
compatPatches: MindmapCompatPayloadPatch[];
};
export type ToolbarCommandInput = {
mindmapId: string;
runtimeCommand: string;
activeNodeId?: string | null;
newNode?: MindmapNodeInput;
layout?: unknown;
theme?: unknown;
themeConfig?: unknown;
};
const defaultNode = (node?: MindmapNodeInput): MindmapNodeInput => ({
text: node?.text?.trim() || "新节点",
...(node?.uid ? { uid: node.uid } : {}),
...(node?.hyperlink ? { hyperlink: node.hyperlink } : {}),
...(node?.note ? { note: node.note } : {}),
...(node?.refs ? { refs: node.refs } : {}),
});
export const createToolbarKernelCommand = (
input: ToolbarCommandInput,
): MindmapKernelCommand | MindmapCompatPayloadPatch | null => {
const activeNodeId = input.activeNodeId?.trim();
switch (input.runtimeCommand) {
case "INSERT_CHILD_NODE":
if (!activeNodeId) return null;
return {
type: "insertChild",
mindmapId: input.mindmapId,
parentNodeId: activeNodeId,
node: defaultNode(input.newNode),
};
case "INSERT_NODE":
if (!activeNodeId) return null;
return {
type: "insertSiblingAfter",
mindmapId: input.mindmapId,
targetNodeId: activeNodeId,
node: defaultNode(input.newNode),
};
case "REMOVE_NODE":
case "DELETE_NODE":
if (!activeNodeId) return null;
return { type: "deleteNode", mindmapId: input.mindmapId, nodeId: activeNodeId };
case "ADD_GENERALIZATION":
return {
type: "compatPayloadPatch",
mindmapId: input.mindmapId,
path: "root.data.generalization",
value: { text: "概要" },
source: "toolbar",
};
case "ADD_OUTER_FRAME":
return {
type: "compatPayloadPatch",
mindmapId: input.mindmapId,
path: "outerFrame",
value: { enabled: true, activeNodeId },
source: "toolbar",
};
default:
return null;
}
};
export const createViewPatchCommand = (
mindmapId: string,
patch: Record<string, unknown>,
): MindmapKernelCommand => ({
type: "patchView",
mindmapId,
patch,
});
export const createThemeCommand = (
mindmapId: string,
theme: unknown,
themeConfig?: unknown,
): MindmapKernelCommand => ({
type: "setTheme",
mindmapId,
theme,
themeConfig,
});
export const createLayoutCommand = (mindmapId: string, layout: unknown): MindmapKernelCommand => ({
type: "setLayout",
mindmapId,
layout,
});
export const createCompatPayloadPatch = (input: {
mindmapId: string;
path: string;
value: unknown;
source: MindmapCompatPayloadPatch["source"];
actionId?: string;
runtimeRevision?: number | null;
kernelRevision?: number | null;
}): MindmapCompatPayloadPatch => ({
type: "compatPayloadPatch",
mindmapId: input.mindmapId,
path: input.path,
value: input.value,
source: input.source,
...(input.actionId ? { actionId: input.actionId } : {}),
...(typeof input.runtimeRevision === "number" ? { runtimeRevision: input.runtimeRevision } : {}),
...(typeof input.kernelRevision === "number" ? { kernelRevision: input.kernelRevision } : {}),
});
type IndexedRuntimeNode = {
uid: string;
parentUid: string | null;
order: number;
text: string;
data: Record<string, unknown>;
node: MindMapData;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readNodeUid = (node: MindMapData, fallback: string): string => {
const uid = node.data?.uid;
return typeof uid === "string" && uid.trim() ? uid.trim() : fallback;
};
const readNodeText = (node: MindMapData): string => {
const text = node.data?.text;
return typeof text === "string" ? text : String(text ?? "");
};
const indexMindmapRuntimeTree = (input: unknown): Map<string, IndexedRuntimeNode> => {
const root = canonicalizeMindmapData(input);
const index = new Map<string, IndexedRuntimeNode>();
const visit = (node: MindMapData, parentUid: string | null, order: number, path: string) => {
const uid = readNodeUid(node, path);
const data = isRecord(node.data) ? node.data : {};
index.set(uid, {
uid,
parentUid,
order,
text: readNodeText(node),
data,
node,
});
const children = Array.isArray(node.children) ? node.children : [];
children.forEach((child, childIndex) => {
visit(canonicalizeMindmapData(child), uid, childIndex, `${path}.${childIndex}`);
});
};
visit(root, null, 0, "root");
return index;
};
const serializeComparable = (value: unknown): string => JSON.stringify(value ?? null);
const buildNodeInputFromRuntimeNode = (node: IndexedRuntimeNode): MindmapNodeInput => ({
uid: node.uid,
text: node.text || "新节点",
...(typeof node.data.hyperlink === "string" ? { hyperlink: node.data.hyperlink } : {}),
...(typeof node.data.note === "string" ? { note: node.data.note } : {}),
...(Array.isArray(node.data.refs) ? { refs: node.data.refs } : {}),
});
const pushCompatDataPatches = (
mindmapId: string,
prev: IndexedRuntimeNode,
next: IndexedRuntimeNode,
compatPatches: MindmapCompatPayloadPatch[],
) => {
const semanticKeys = new Set(["uid", "text", "refs"]);
const keys = new Set([...Object.keys(prev.data), ...Object.keys(next.data)]);
keys.forEach((key) => {
if (semanticKeys.has(key)) return;
if (serializeComparable(prev.data[key]) === serializeComparable(next.data[key])) return;
compatPatches.push(
createCompatPayloadPatch({
mindmapId,
path: `nodes.${next.uid}.data.${key}`,
value: next.data[key],
source: "adapter-diff",
}),
);
});
};
export const diffMindmapRuntimeDataToKernelCommands = (input: {
mindmapId: string;
previous: unknown;
next: unknown;
}): MindmapCommandDiffResult => {
const previousIndex = indexMindmapRuntimeTree(input.previous);
const nextIndex = indexMindmapRuntimeTree(input.next);
const commands: MindmapKernelCommand[] = [];
const compatPatches: MindmapCompatPayloadPatch[] = [];
nextIndex.forEach((nextNode, uid) => {
const previousNode = previousIndex.get(uid);
if (!previousNode) {
const previousSibling = Array.from(nextIndex.values()).find(
(candidate) =>
candidate.parentUid === nextNode.parentUid &&
candidate.order === nextNode.order - 1 &&
previousIndex.has(candidate.uid),
);
if (previousSibling) {
commands.push({
type: "insertSiblingAfter",
mindmapId: input.mindmapId,
targetNodeId: previousSibling.uid,
node: buildNodeInputFromRuntimeNode(nextNode),
});
} else if (nextNode.parentUid) {
commands.push({
type: "insertChild",
mindmapId: input.mindmapId,
parentNodeId: nextNode.parentUid,
node: buildNodeInputFromRuntimeNode(nextNode),
});
}
return;
}
if (previousNode.text !== nextNode.text) {
commands.push({
type: "updateText",
mindmapId: input.mindmapId,
nodeId: uid,
text: nextNode.text,
});
}
if (
previousNode.parentUid !== nextNode.parentUid ||
previousNode.order !== nextNode.order
) {
commands.push({
type: "moveNode",
mindmapId: input.mindmapId,
nodeId: uid,
newParentNodeId: nextNode.parentUid ?? "",
order: nextNode.order,
});
}
pushCompatDataPatches(input.mindmapId, previousNode, nextNode, compatPatches);
});
previousIndex.forEach((previousNode, uid) => {
if (!nextIndex.has(uid) && previousNode.parentUid) {
commands.push({ type: "deleteNode", mindmapId: input.mindmapId, nodeId: uid });
}
});
return { commands, compatPatches };
};
@@ -1,3 +1,6 @@
// 说明:这里保留的是 legacy/blob compat projection 工具,服务导图导入、
// 旧 React mindmap 参考实现和独立页兼容。Phase 6 文档页默认编辑主链
// 使用 Rust kernel projection 与 simple-mind-map adapter projection。
export type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
@@ -22,8 +25,11 @@ export type MindmapProjectionNode = {
};
export type MindmapProjection = {
schema: "mnote.mindmap_projection.v1";
projectionId: string;
projection: "mindmap_subtree";
source: string;
owner: string;
documentId: string;
mindmapId: string;
rootNodeId: string | null;
@@ -34,6 +40,56 @@ export type MindmapProjection = {
meta: MindmapRouteMeta | null;
};
export type MindmapEditorSceneNode = MindmapProjectionNode & {
id: string;
parentId: string | null;
};
export type MindmapEditorSceneEdge = {
id: string;
source: string;
target: string;
};
export type MindmapEditorScene = {
schema: "mnote.mindmap_editor_scene.v1";
source: string;
owner: string;
documentId: string;
mindmapId: string;
rootNodeId: string | null;
title: string;
nodes: MindmapEditorSceneNode[];
edges: MindmapEditorSceneEdge[];
capabilities: {
canEditText: boolean;
canAddChild: boolean;
canAddSiblingAfter: boolean;
canDeleteNode: boolean;
};
data: MindMapData;
meta: MindmapRouteMeta | null;
};
export type MindmapSimpleMindMapScene = {
schema: "mnote.mindmap.simple_mind_map_scene.v1";
runtime: "simple-mind-map";
documentId: string;
mindmapId: string;
rootNodeId: string | null;
root: MindMapData;
layout: unknown;
theme: unknown;
themeConfig: unknown;
view: unknown;
config: unknown;
compatPayload: unknown;
kernelRevision: number;
source: string;
owner: string;
meta: MindmapRouteMeta | null;
};
export const defaultMindmapData: MindMapData = {
data: { text: "中心主题" },
children: [],
@@ -49,13 +105,12 @@ export const normalizeMindmapData = (input: unknown): unknown => {
const walk = (node: unknown) => {
if (!isRecord(node)) return;
if (!isRecord(node.data)) {
node.data = {};
}
const rawText = node.data.text;
node.data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");
const data = isRecord(node.data) ? node.data : {};
node.data = data;
const rawText = data.text;
data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");
const gen = node.data.generalization;
const gen = data.generalization;
const fixGen = (value: unknown) => {
if (!isRecord(value)) return;
const text = value.text;
@@ -128,6 +183,8 @@ export const buildMindmapProjection = (input: {
documentId: string;
mindmapId: string;
data: unknown;
source?: string;
owner?: string;
meta?: unknown;
}): MindmapProjection => {
const data = canonicalizeMindmapData(input.data);
@@ -136,8 +193,11 @@ export const buildMindmapProjection = (input: {
const rootNodeId = nodes[0]?.uid ?? null;
return {
schema: "mnote.mindmap_projection.v1",
projectionId: `mindmap_projection:${input.documentId}:${input.mindmapId}`,
projection: "mindmap_subtree",
source: input.source ?? "rust-kernel",
owner: input.owner ?? "rust-kernel",
documentId: input.documentId,
mindmapId: input.mindmapId,
rootNodeId,
@@ -148,3 +208,106 @@ export const buildMindmapProjection = (input: {
meta,
};
};
export const buildMindmapEditorScene = (input: {
documentId: string;
mindmapId: string;
rootNodeId?: string | null;
data: unknown;
source?: string;
owner?: string;
meta?: unknown;
}): MindmapEditorScene => {
const data = canonicalizeMindmapData(input.data);
const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
const nodes: MindmapEditorSceneNode[] = [];
const edges: MindmapEditorSceneEdge[] = [];
const walk = (node: MindMapData, depth: number, parentId: string | null) => {
const uidRaw = node?.data?.uid;
const textRaw = node?.data?.text;
const id =
typeof uidRaw === "string" && uidRaw.trim()
? uidRaw.trim()
: `depth:${depth}:index:${nodes.length}`;
const children = Array.isArray(node?.children) ? node.children : [];
nodes.push({
id,
uid: id,
text: typeof textRaw === "string" && textRaw.trim() ? textRaw.trim() : "未命名节点",
depth,
childCount: children.length,
parentId,
});
if (parentId) {
edges.push({
id: `${parentId}->${id}`,
source: parentId,
target: id,
});
}
children.forEach((child) => {
walk(canonicalizeMindmapData(child), depth + 1, id);
});
};
walk(data, 0, null);
const fallbackRootId = nodes[0]?.id ?? null;
const rootNodeId = input.rootNodeId && input.rootNodeId.trim() ? input.rootNodeId.trim() : fallbackRootId;
return {
schema: "mnote.mindmap_editor_scene.v1",
source: input.source ?? "rust-kernel",
owner: input.owner ?? "rust-kernel",
documentId: input.documentId,
mindmapId: input.mindmapId,
rootNodeId,
title: extractMindmapTitle(data),
nodes,
edges,
capabilities: {
canEditText: true,
canAddChild: true,
canAddSiblingAfter: true,
canDeleteNode: true,
},
data,
meta,
};
};
export const buildMindmapSimpleMindMapScene = (input: {
documentId: string;
mindmapId: string;
data: unknown;
source?: string;
owner?: string;
meta?: unknown;
}): MindmapSimpleMindMapScene => {
const root = canonicalizeMindmapData(input.data);
const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
const rootNodeIdRaw = root?.data?.uid;
const rootNodeId = typeof rootNodeIdRaw === "string" && rootNodeIdRaw.trim() ? rootNodeIdRaw.trim() : "root";
if (!root.data.uid) {
root.data.uid = rootNodeId;
}
return {
schema: "mnote.mindmap.simple_mind_map_scene.v1",
runtime: "simple-mind-map",
documentId: input.documentId,
mindmapId: input.mindmapId,
rootNodeId,
root,
layout: "logicalStructure",
theme: "classic",
themeConfig: {},
view: { x: 0, y: 0, scale: 1 },
config: {},
compatPayload: {},
kernelRevision: 1,
source: input.source ?? "rust-kernel",
owner: input.owner ?? "rust-kernel",
meta,
};
};
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
MINDMAP_DEBUG_CHROME_QUERY_PARAM,
isMindmapDebugChromeEnabled,
mindmapDefaultUiSchema,
} from "./mindmap-ui-schema";
import { getMindmapActionMapping } from "./mindmap-action-map";
describe("mindmap UI schema", () => {
it("默认 schema 暴露第一阶段 UI shell 分区", () => {
expect(mindmapDefaultUiSchema.toolbarGroups.map((group) => group.id)).toEqual([
"history",
"node",
"insert",
"view",
]);
expect(mindmapDefaultUiSchema.sidebarPanels.map((panel) => panel.id)).toEqual([
"nodeStyle",
"baseStyle",
"theme",
"structure",
"outline",
]);
expect(mindmapDefaultUiSchema.navigatorItems.map((item) => item.id)).toEqual([
"stats",
"centerRoot",
"search",
"zoomOut",
"zoom",
"zoomIn",
"readonly",
]);
});
it("默认不启用旧手写 chrome,只有显式 debug 参数才启用", () => {
expect(MINDMAP_DEBUG_CHROME_QUERY_PARAM).toBe("mnoteMindmapDebugChrome");
expect(isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1")).toBe(false);
expect(
isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1?mnoteMindmapDebugChrome=1"),
).toBe(true);
expect(
isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1?mnoteMindmapDebugChrome=true"),
).toBe(true);
});
it("toolbar action id 不重复", () => {
const actionIds = mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions);
expect(new Set(actionIds).size).toBe(actionIds.length);
});
it("默认 schema 中所有 action 都有 action map 映射", () => {
const actionIds = [
...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
...mindmapDefaultUiSchema.navigatorItems.flatMap((item) => (item.actionId ? [item.actionId] : [])),
...mindmapDefaultUiSchema.contextMenuItems.map((item) => item.actionId),
];
expect(actionIds.filter((actionId) => getMindmapActionMapping(actionId) === null)).toEqual([]);
});
it("第一阶段 context menu 覆盖节点和画布动作", () => {
expect(mindmapDefaultUiSchema.contextMenuItems.filter((item) => item.requiresNode).map((item) => item.actionId)).toEqual([
"insertChild",
"insertSiblingAfter",
"deleteNode",
"summary",
"associativeLine",
"expandCollapse",
"copyNodeText",
]);
expect(mindmapDefaultUiSchema.contextMenuItems.filter((item) => !item.requiresNode).map((item) => item.actionId)).toEqual([
"centerRoot",
"fitView",
"search",
"readonly",
]);
});
it("第一阶段 sidebar panel 数量受控", () => {
expect(mindmapDefaultUiSchema.sidebarPanels).toHaveLength(5);
});
it("第一阶段 sidebar 暴露主题、结构和 compat patch 选项", () => {
const themePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "theme");
expect(themePanel?.options.map((option) => option.value)).toEqual(["classic", "classic4"]);
const structurePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "structure");
expect(structurePanel?.options.map((option) => option.value)).toEqual([
"logicalStructure",
"mindMap",
"fishbone",
]);
const nodeStylePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "nodeStyle");
const baseStylePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "baseStyle");
expect(nodeStylePanel?.options.every((option) => Boolean(option.compatPath))).toBe(true);
expect(baseStylePanel?.options.every((option) => Boolean(option.compatPath))).toBe(true);
});
});
@@ -0,0 +1,192 @@
export const MINDMAP_DEBUG_CHROME_QUERY_PARAM = "mnoteMindmapDebugChrome";
export type MindmapUiActionId =
| "undo"
| "redo"
| "editNode"
| "insertSiblingAfter"
| "insertChild"
| "deleteNode"
| "tag"
| "hyperlink"
| "note"
| "image"
| "icon"
| "summary"
| "associativeLine"
| "formula"
| "painter"
| "import"
| "export"
| "setTheme"
| "setLayout"
| "zoomIn"
| "zoomOut"
| "fitView"
| "centerRoot"
| "search"
| "expandCollapse"
| "copyNodeText"
| "readonly";
export type MindmapToolbarGroup = {
id: "history" | "node" | "insert" | "view";
label: string;
actions: MindmapUiActionId[];
collapsePriority: number;
};
export type MindmapSidebarPanel = {
id: "nodeStyle" | "baseStyle" | "theme" | "structure" | "outline";
label: string;
icon: string;
runtimeCapability: string;
phase: 1;
options: MindmapSidebarOption[];
};
export type MindmapSidebarOption = {
id: string;
label: string;
actionId: MindmapUiActionId | null;
value: unknown;
compatPath?: string;
};
export type MindmapNavigatorItem = {
id: "stats" | "centerRoot" | "search" | "zoomOut" | "zoom" | "zoomIn" | "readonly";
label: string;
actionId: MindmapUiActionId | null;
readOnly: boolean;
displayMode: "text" | "button" | "input";
};
export type MindmapContextMenuItem = {
id: string;
label: string;
actionId: MindmapUiActionId;
requiresNode: boolean;
phase: 1;
};
export type MindmapUiSchema = {
toolbarGroups: MindmapToolbarGroup[];
sidebarPanels: MindmapSidebarPanel[];
navigatorItems: MindmapNavigatorItem[];
contextMenuItems: MindmapContextMenuItem[];
};
export const mindmapDefaultUiSchema: MindmapUiSchema = {
toolbarGroups: [
{
id: "history",
label: "历史",
actions: ["undo", "redo"],
collapsePriority: 4,
},
{
id: "node",
label: "节点",
actions: ["editNode", "insertSiblingAfter", "insertChild", "deleteNode"],
collapsePriority: 1,
},
{
id: "insert",
label: "插入",
actions: ["tag", "hyperlink", "note", "image", "icon", "summary", "associativeLine", "formula", "painter", "import", "export"],
collapsePriority: 2,
},
{
id: "view",
label: "视图",
actions: ["centerRoot", "zoomOut", "zoomIn", "search", "readonly"],
collapsePriority: 3,
},
],
sidebarPanels: [
{
id: "nodeStyle",
label: "节点样式",
icon: "palette",
runtimeCapability: "node-style",
phase: 1,
options: [
{ id: "node-fill-blue", label: "蓝色节点", actionId: "painter", value: "#dbeafe", compatPath: "nodes.$active.data.fillColor" },
{ id: "node-round", label: "圆角节点", actionId: "painter", value: "roundedRectangle", compatPath: "nodes.$active.data.shape" },
],
},
{
id: "baseStyle",
label: "导图样式",
icon: "sliders",
runtimeCapability: "base-style",
phase: 1,
options: [
{ id: "base-curve-line", label: "曲线连线", actionId: "painter", value: "curve", compatPath: "style.map.lineStyle" },
{ id: "base-rainbow-lines", label: "彩虹线条", actionId: "painter", value: { enabled: true }, compatPath: "style.map.rainbowLines" },
],
},
{
id: "theme",
label: "主题",
icon: "swatch",
runtimeCapability: "theme",
phase: 1,
options: [
{ id: "theme-classic", label: "默认主题", actionId: "setTheme", value: "classic" },
{ id: "theme-classic4", label: "KMind-like", actionId: "setTheme", value: "classic4" },
],
},
{
id: "structure",
label: "结构",
icon: "layout",
runtimeCapability: "layout",
phase: 1,
options: [
{ id: "layout-logical", label: "逻辑结构", actionId: "setLayout", value: "logicalStructure" },
{ id: "layout-mind-map", label: "右侧结构", actionId: "setLayout", value: "mindMap" },
{ id: "layout-fishbone", label: "鱼骨结构", actionId: "setLayout", value: "fishbone" },
],
},
{
id: "outline",
label: "大纲",
icon: "list-tree",
runtimeCapability: "outline",
phase: 1,
options: [],
},
],
navigatorItems: [
{ id: "stats", label: "统计", actionId: null, readOnly: true, displayMode: "text" },
{ id: "centerRoot", label: "回根节点", actionId: "centerRoot", readOnly: true, displayMode: "button" },
{ id: "search", label: "搜索", actionId: "search", readOnly: true, displayMode: "input" },
{ id: "zoomOut", label: "缩小", actionId: "zoomOut", readOnly: true, displayMode: "button" },
{ id: "zoom", label: "缩放", actionId: null, readOnly: true, displayMode: "text" },
{ id: "zoomIn", label: "放大", actionId: "zoomIn", readOnly: true, displayMode: "button" },
{ id: "readonly", label: "只读", actionId: "readonly", readOnly: true, displayMode: "button" },
],
contextMenuItems: [
{ id: "insertChild", label: "插入子节点", actionId: "insertChild", requiresNode: true, phase: 1 },
{ id: "insertSiblingAfter", label: "插入同级节点", actionId: "insertSiblingAfter", requiresNode: true, phase: 1 },
{ id: "deleteNode", label: "删除节点", actionId: "deleteNode", requiresNode: true, phase: 1 },
{ id: "summary", label: "概要", actionId: "summary", requiresNode: true, phase: 1 },
{ id: "associativeLine", label: "关联线", actionId: "associativeLine", requiresNode: true, phase: 1 },
{ id: "expandCollapse", label: "展开/收起", actionId: "expandCollapse", requiresNode: true, phase: 1 },
{ id: "copyNodeText", label: "复制文本", actionId: "copyNodeText", requiresNode: true, phase: 1 },
{ id: "centerRoot", label: "回根节点", actionId: "centerRoot", requiresNode: false, phase: 1 },
{ id: "fitView", label: "适应画布", actionId: "fitView", requiresNode: false, phase: 1 },
{ id: "search", label: "搜索", actionId: "search", requiresNode: false, phase: 1 },
{ id: "readonly", label: "只读切换", actionId: "readonly", requiresNode: false, phase: 1 },
],
};
export const isMindmapDebugChromeEnabled = (href: string): boolean => {
try {
const value = new URL(href).searchParams.get(MINDMAP_DEBUG_CHROME_QUERY_PARAM);
return value === "1" || value === "true";
} catch {
return false;
}
};
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { deriveMindmapUiState } from "./mindmap-ui-state";
describe("mindmap UI state", () => {
it("无 active node 时禁用节点编辑动作,但保留视图动作", () => {
const state = deriveMindmapUiState({ activeNodeId: null, readonly: false });
expect(state.disabledActions.insertChild).toBe(true);
expect(state.disabledActions.insertSiblingAfter).toBe(true);
expect(state.disabledActions.deleteNode).toBe(true);
expect(state.disabledActions.expandCollapse).toBe(true);
expect(state.disabledActions.zoomIn).toBe(false);
expect(state.disabledActions.centerRoot).toBe(false);
});
it("readonly 时禁用编辑动作,保留搜索和视图动作", () => {
const state = deriveMindmapUiState({ activeNodeId: "node_1", readonly: true });
expect(state.disabledActions.insertChild).toBe(true);
expect(state.disabledActions.note).toBe(true);
expect(state.disabledActions.setTheme).toBe(true);
expect(state.disabledActions.expandCollapse).toBe(true);
expect(state.disabledActions.copyNodeText).toBe(false);
expect(state.disabledActions.search).toBe(false);
expect(state.disabledActions.zoomOut).toBe(false);
});
it("缺少 runtime capability 时禁用对应 action", () => {
const state = deriveMindmapUiState({
activeNodeId: "node_1",
readonly: false,
runtimeCapabilities: ["runtimeCommand", "kernelCommand"],
});
expect(state.disabledActions.note).toBe(true);
expect(state.disabledActions.insertChild).toBe(false);
});
});
@@ -0,0 +1,64 @@
import { getMindmapActionMapping, listMindmapActionMappings, type MindmapActionTarget } from "./mindmap-action-map";
import { mindmapDefaultUiSchema, type MindmapUiActionId } from "./mindmap-ui-schema";
export type MindmapRuntimeCapability = MindmapActionTarget;
export type MindmapUiStateInput = {
activeNodeId?: string | null;
readonly: boolean;
runtimeCapabilities?: MindmapRuntimeCapability[];
};
export type MindmapUiState = {
activeNodeId: string | null;
readonly: boolean;
disabledActions: Record<MindmapUiActionId, boolean>;
};
const allActionIds = (): MindmapUiActionId[] => {
const ids = [
...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
...mindmapDefaultUiSchema.navigatorItems.flatMap((item) => (item.actionId ? [item.actionId] : [])),
...mindmapDefaultUiSchema.contextMenuItems.map((item) => item.actionId),
...listMindmapActionMappings().map((mapping) => mapping.actionId),
];
return [...new Set(ids)];
};
const normalizeActiveNodeId = (value: string | null | undefined): string | null => {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
};
export const deriveMindmapUiState = (input: MindmapUiStateInput): MindmapUiState => {
const activeNodeId = normalizeActiveNodeId(input.activeNodeId);
const capabilities = input.runtimeCapabilities ? new Set<MindmapRuntimeCapability>(input.runtimeCapabilities) : null;
const disabledActions = {} as Record<MindmapUiActionId, boolean>;
allActionIds().forEach((actionId) => {
const mapping = getMindmapActionMapping(actionId);
if (!mapping) {
disabledActions[actionId] = true;
return;
}
if (mapping.requiresActiveNode && !activeNodeId) {
disabledActions[actionId] = true;
return;
}
if (input.readonly && !mapping.readonlyAllowed) {
disabledActions[actionId] = true;
return;
}
if (capabilities && !capabilities.has(mapping.target)) {
disabledActions[actionId] = true;
return;
}
disabledActions[actionId] = false;
});
return {
activeNodeId,
readonly: input.readonly,
disabledActions,
};
};
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
SIMPLE_MIND_MAP_BRIDGE_EVENTS,
SIMPLE_MIND_MAP_REQUIRED_PLUGINS,
buildSimpleMindMapOptions,
createFallbackMindmapAdapterProjection,
createSafeMindmapCommandExecutor,
defaultSimpleMindMapPluginNames,
} from "./simple-mind-map-bridge";
describe("simple-mind-map bridge contract", () => {
it("登记 Phase 6 第一阶段必需插件和事件", () => {
expect(SIMPLE_MIND_MAP_REQUIRED_PLUGINS).toEqual([
"Drag",
"KeyboardNavigation",
"Export",
"Select",
"AssociativeLine",
"Search",
"OuterFrame",
]);
expect(defaultSimpleMindMapPluginNames()).toEqual(
expect.arrayContaining(["Scrollbar", "MiniMap", "Painter", "Formula"]),
);
expect(defaultSimpleMindMapPluginNames()).not.toContain("RichText");
expect(SIMPLE_MIND_MAP_BRIDGE_EVENTS).toEqual([
"data_change",
"view_data_change",
"node_active",
"back_forward",
"scale",
"translate",
]);
});
it("从 adapter projection 构建 simple-mind-map 初始化参数", () => {
const projection = createFallbackMindmapAdapterProjection({
mindmapId: "mind_1",
root: { data: { text: "KMIND" }, children: [] },
kernelRevision: 7,
});
const options = buildSimpleMindMapOptions({
el: {} as HTMLElement,
projection: {
...projection,
theme: { template: "classic4" },
themeConfig: { root: { fillColor: "#c62828" } },
view: { state: { scale: 0.8, x: 1, y: 2 }, transform: { scaleX: 0.8, scaleY: 0.8 } },
},
});
expect(options.data).toEqual({ data: { text: "KMIND" }, children: [] });
expect(options.layout).toBe("logicalStructure");
expect(options.theme).toBe("classic4");
expect(options.themeConfig).toEqual({ root: { fillColor: "#c62828" } });
expect(options.viewData).toEqual({ state: { scale: 0.8, x: 1, y: 2 }, transform: { scaleX: 0.8, scaleY: 0.8 } });
});
it("保留 runtimeOptions 中的 fit 设置,避免初始导图被裁切", () => {
const projection = createFallbackMindmapAdapterProjection({
mindmapId: "mind_1",
root: { data: { text: "KMIND" }, children: [] },
kernelRevision: 7,
});
expect(
buildSimpleMindMapOptions({
el: {} as HTMLElement,
projection,
}).fit,
).toBe(true);
expect(
buildSimpleMindMapOptions({
el: {} as HTMLElement,
projection,
runtimeOptions: { fit: false },
}).fit,
).toBe(false);
});
it("忽略无效 viewData,避免 simple-mind-map 读取 viewData.state 时崩溃", () => {
const projection = createFallbackMindmapAdapterProjection({
mindmapId: "mind_1",
root: { data: { text: "KMIND" }, children: [] },
kernelRevision: 7,
});
const options = buildSimpleMindMapOptions({
el: {} as HTMLElement,
projection: { ...projection, view: {} },
});
expect(options.viewData).toBeNull();
});
it("安全命令包装只允许受控 runtime command", () => {
const calls: unknown[][] = [];
const exec = createSafeMindmapCommandExecutor({
execCommand: (...args) => calls.push(args),
});
expect(exec("INSERT_CHILD_NODE", "node_1")).toMatchObject({ ok: true });
expect(exec("SAVE_FULL_BLOB", { data: {} })).toEqual({
ok: false,
command: "SAVE_FULL_BLOB",
error: "unsupported_command",
});
expect(calls).toEqual([["INSERT_CHILD_NODE", "node_1"]]);
});
});
@@ -0,0 +1,310 @@
import {
canonicalizeMindmapData,
defaultMindmapData,
type MindMapData,
} from "./mindmap-projection";
export type MindmapAdapterProjection = {
schema: "mnote.mindmap.simple_mind_map_scene.v1";
runtime: "simple-mind-map" | string;
root: unknown;
layout?: unknown;
theme?: unknown;
themeConfig?: unknown;
view?: unknown;
config?: unknown;
compatPayload?: unknown;
kernelRevision: number;
};
export type SimpleMindMapInstance = {
execCommand?: (command: string, ...args: unknown[]) => unknown;
destroy?: () => void;
getData?: (withConfig?: boolean) => unknown;
on?: (event: string, handler: (...args: unknown[]) => void) => void;
off?: (event: string, handler: (...args: unknown[]) => void) => void;
setMode?: (mode: "edit" | "readonly") => void;
view?: {
scale?: number;
enlarge?: () => void;
narrow?: () => void;
getTransformData?: () => unknown;
setScale?: (scale: number, cx: number, cy: number) => void;
};
renderer?: {
setRootNodeCenter?: () => void;
};
};
export type SimpleMindMapCtor = new (options: Record<string, unknown>) => SimpleMindMapInstance;
export type SimpleMindMapRuntime = {
MindMap: SimpleMindMapCtor;
registeredPlugins: string[];
};
export type SimpleMindMapBridgeEventName =
| "data_change"
| "view_data_change"
| "node_active"
| "back_forward"
| "scale"
| "translate";
export type SimpleMindMapBridgeEvent = {
type: SimpleMindMapBridgeEventName;
args: unknown[];
snapshot?: unknown;
kernelRevision: number;
};
export type SimpleMindMapBridge = {
instance: SimpleMindMapInstance;
execCommand: (command: string, ...args: unknown[]) => SimpleMindMapSafeCommandResult;
getSnapshot: () => unknown;
destroy: () => void;
};
export type SimpleMindMapSafeCommandResult =
| { ok: true; command: string; result: unknown }
| { ok: false; command: string; error: "unsupported_command" | "runtime_unavailable" | "command_failed" };
export const SIMPLE_MIND_MAP_REQUIRED_PLUGINS = [
"Drag",
"KeyboardNavigation",
"Export",
"Select",
"AssociativeLine",
"Search",
"OuterFrame",
] as const;
export const SIMPLE_MIND_MAP_OPTIONAL_PLUGINS = [
// RichText 会把纯文本节点转成 HTML 字符串;Phase 6.1 先保护 kernel 文本语义。
"Scrollbar",
"MiniMap",
"Painter",
"Formula",
] as const;
export const SIMPLE_MIND_MAP_BRIDGE_EVENTS: SimpleMindMapBridgeEventName[] = [
"data_change",
"view_data_change",
"node_active",
"back_forward",
"scale",
"translate",
];
export const SIMPLE_MIND_MAP_SAFE_COMMANDS = new Set([
"BACK",
"FORWARD",
"INSERT_NODE",
"INSERT_CHILD_NODE",
"REMOVE_NODE",
"DELETE_NODE",
"ADD_GENERALIZATION",
"ADD_OUTER_FRAME",
"SET_NOTATION",
]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readString = (value: unknown, fallback: string): string => {
if (typeof value === "string" && value.trim()) return value.trim();
if (isRecord(value)) {
const template = value.template;
if (typeof template === "string" && template.trim()) return template.trim();
}
return fallback;
};
const readRecord = (value: unknown): Record<string, unknown> => {
if (isRecord(value)) return value;
return {};
};
const readViewData = (value: unknown): Record<string, unknown> | null => {
if (!isRecord(value) || !isRecord(value.state)) return null;
return {
...value,
state: value.state,
transform: isRecord(value.transform) ? value.transform : {},
};
};
export const buildSimpleMindMapOptions = (input: {
el: HTMLElement;
projection: MindmapAdapterProjection;
runtimeOptions?: Record<string, unknown>;
}): Record<string, unknown> => {
const root = canonicalizeMindmapData(input.projection.root ?? defaultMindmapData);
const config = readRecord(input.projection.config);
const runtimeOptions = readRecord(input.runtimeOptions);
return {
...config,
...runtimeOptions,
el: input.el,
data: root,
fit:
typeof runtimeOptions.fit === "boolean"
? runtimeOptions.fit
: typeof config.fit === "boolean"
? config.fit
: true,
layout: readString(input.projection.layout, "logicalStructure"),
theme: readString(input.projection.theme, "classic"),
themeConfig: readRecord(input.projection.themeConfig),
viewData: readViewData(input.projection.view),
initRootNodePosition: ["center", "center"],
};
};
const pluginImporters = {
Drag: () => import("simple-mind-map/src/plugins/Drag.js"),
KeyboardNavigation: () => import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
Export: () => import("simple-mind-map/src/plugins/Export.js"),
Select: () => import("simple-mind-map/src/plugins/Select.js"),
AssociativeLine: () => import("simple-mind-map/src/plugins/AssociativeLine.js"),
Search: () => import("simple-mind-map/src/plugins/Search.js"),
OuterFrame: () => import("simple-mind-map/src/plugins/OuterFrame.js"),
Scrollbar: () => import("simple-mind-map/src/plugins/Scrollbar.js"),
MiniMap: () => import("simple-mind-map/src/plugins/MiniMap.js"),
Painter: () => import("simple-mind-map/src/plugins/Painter.js"),
Formula: () => import("simple-mind-map/src/plugins/Formula.js"),
} satisfies Record<string, () => Promise<{ default: unknown }>>;
export type SimpleMindMapPluginName = keyof typeof pluginImporters;
export const defaultSimpleMindMapPluginNames = (): SimpleMindMapPluginName[] => [
...SIMPLE_MIND_MAP_REQUIRED_PLUGINS,
...SIMPLE_MIND_MAP_OPTIONAL_PLUGINS,
];
export const loadSimpleMindMapRuntime = async (
pluginNames: SimpleMindMapPluginName[] = defaultSimpleMindMapPluginNames(),
): Promise<SimpleMindMapRuntime> => {
if (typeof window === "undefined" || typeof document === "undefined") {
throw new Error("simple_mind_map_browser_required");
}
const [{ default: MindMap }, pluginModules] = await Promise.all([
import("simple-mind-map"),
Promise.all(pluginNames.map(async (name) => [name, (await pluginImporters[name]()).default] as const)),
]);
const MindMapCtor = MindMap as unknown as SimpleMindMapCtor & {
hasPlugin?: (plugin: unknown) => number;
usePlugin?: (plugin: unknown) => unknown;
};
const registeredPlugins: string[] = [];
pluginModules.forEach(([name, plugin]) => {
if (!plugin || typeof MindMapCtor.usePlugin !== "function") return;
const registered =
typeof MindMapCtor.hasPlugin === "function" ? MindMapCtor.hasPlugin(plugin) !== -1 : false;
if (!registered) {
MindMapCtor.usePlugin(plugin);
}
registeredPlugins.push(name);
});
return {
MindMap: MindMapCtor,
registeredPlugins,
};
};
export const createSafeMindmapCommandExecutor =
(instance: SimpleMindMapInstance) =>
(command: string, ...args: unknown[]): SimpleMindMapSafeCommandResult => {
if (!SIMPLE_MIND_MAP_SAFE_COMMANDS.has(command)) {
return { ok: false, command, error: "unsupported_command" };
}
if (typeof instance.execCommand !== "function") {
return { ok: false, command, error: "runtime_unavailable" };
}
try {
return { ok: true, command, result: instance.execCommand(command, ...args) };
} catch {
return { ok: false, command, error: "command_failed" };
}
};
export const attachSimpleMindMapEventListeners = (input: {
instance: SimpleMindMapInstance;
projection: MindmapAdapterProjection;
onEvent?: (event: SimpleMindMapBridgeEvent) => void;
}): (() => void) => {
const cleanup: Array<() => void> = [];
SIMPLE_MIND_MAP_BRIDGE_EVENTS.forEach((eventName) => {
const handler = (...args: unknown[]) => {
const snapshot =
eventName === "data_change" || eventName === "view_data_change"
? input.instance.getData?.(true) ?? input.instance.getData?.()
: undefined;
input.onEvent?.({
type: eventName,
args,
snapshot,
kernelRevision: input.projection.kernelRevision,
});
};
input.instance.on?.(eventName, handler);
cleanup.push(() => input.instance.off?.(eventName, handler));
});
return () => cleanup.splice(0).forEach((dispose) => dispose());
};
export const createSimpleMindMapBridge = async (input: {
el: HTMLElement;
projection: MindmapAdapterProjection;
pluginNames?: SimpleMindMapPluginName[];
runtimeOptions?: Record<string, unknown>;
mode?: "edit" | "readonly";
onEvent?: (event: SimpleMindMapBridgeEvent) => void;
}): Promise<SimpleMindMapBridge> => {
const runtime = await loadSimpleMindMapRuntime(input.pluginNames);
const options = buildSimpleMindMapOptions({
el: input.el,
projection: input.projection,
runtimeOptions: input.runtimeOptions,
});
const instance = new runtime.MindMap(options);
if (input.mode) instance.setMode?.(input.mode);
const detachEvents = attachSimpleMindMapEventListeners({
instance,
projection: input.projection,
onEvent: input.onEvent,
});
const execCommand = createSafeMindmapCommandExecutor(instance);
return {
instance,
execCommand,
getSnapshot: () => instance.getData?.(true) ?? instance.getData?.(),
destroy: () => {
detachEvents();
instance.destroy?.();
},
};
};
export const createFallbackMindmapAdapterProjection = (input: {
mindmapId: string;
root?: MindMapData;
kernelRevision?: number;
}): MindmapAdapterProjection => ({
schema: "mnote.mindmap.simple_mind_map_scene.v1",
runtime: "simple-mind-map",
root: input.root ?? defaultMindmapData,
layout: "logicalStructure",
theme: "classic",
themeConfig: {},
view: {},
config: {},
compatPayload: { source: "frontend-fallback", mindmapId: input.mindmapId },
kernelRevision: input.kernelRevision ?? 1,
});
@@ -22,6 +22,10 @@ declare module "simple-mind-map/src/plugins/AssociativeLine.js" {
const plugin: unknown;
export default plugin;
}
declare module "simple-mind-map/src/plugins/Search.js" {
const plugin: unknown;
export default plugin;
}
declare module "simple-mind-map/src/plugins/OuterFrame.js" {
const plugin: unknown;
export default plugin;