feat: 收口 mindmap Phase 6 Leptos UI shell
This commit is contained in:
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 adapter,Rust 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 layer;smoke 覆盖 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` action,NodeView 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 patch;smoke 输出 `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 插入均通过 smoke;sidebar 节点样式/导图样式走 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 files,416 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 UI;debug 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 files,418 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 覆盖
|
||||
- [ ] `/` 插入后默认生成示例骨架,且不是空白壳
|
||||
- [ ] 默认隐藏 chrome,hover 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
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms);
|
||||
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(¤t, &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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
+38
-11
@@ -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) {
|
||||
|
||||
BIN
Binary file not shown.
Vendored
+3
-1
@@ -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;
|
||||
|
||||
+1520
-1
File diff suppressed because one or more lines are too long
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;
|
||||
|
||||
Reference in New Issue
Block a user