From b5eb27fabc5315a5debf4f03f603bfe1060475c1 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Mon, 11 May 2026 12:27:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E5=8F=A3=20mindmap=20Phase=20?= =?UTF-8?q?6=20Leptos=20UI=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...dmap-kernel-phase6-projection-editor-v1.md | 1134 ++++-------- ...ndmap-leptos-adapter-reference-notes-v1.md | 222 +++ ...ase6-leptos-ui-shell-reuse-checklist-v1.md | 484 ++++++ ...dmap-phase6-projection-editor-checklist.md | 578 +++---- .../src/app/dev/mindmap/page.tsx | 0 rust/crates/bridge-runtime/src/lib.rs | 1000 ++++++++++- rust/crates/core-protocol/src/lib.rs | 194 ++- rust/crates/core-protocol/src/mindmap.rs | 187 ++ .../mnote-web/src/routes/mindmap_api.rs | 207 +++ .../mnote-web/src/routes/mindmap_shell.rs | 89 +- rust/crates/mnote-web/src/routes/mod.rs | 5 + .../crates/mnote-web/src/ssr/pages/mindmap.rs | 10 +- .../mnote-leptos-tiptap-spike-island.d.ts | 8 +- .../mnote-leptos-tiptap-spike-island.js | 49 +- .../mnote-leptos-tiptap-spike-island_bg.wasm | Bin 9656737 -> 10513412 bytes ...te-leptos-tiptap-spike-island_bg.wasm.d.ts | 4 +- .../src/js/generated/tiptap_paragraph.js | 1521 ++++++++++++++++- rust/spikes/leptos-tiptap-spike/src/lib.rs | 979 ++++++++++- scripts/task166-mindmap-phase6-block-smoke.js | 978 +++++++++++ .../scripts/build-leptos-tiptap-island.js | 6 + .../api/mindmap/[docId]/[mindmapId]/route.ts | 46 +- .../app/mindmap/[docId]/[mindmapId]/page.tsx | 97 +- .../components/editor/blocks/MindmapBlock.tsx | 9 +- .../editor/blocks/MindmapContextMenu.tsx | 11 +- .../components/editor/blocks/MindmapCount.tsx | 3 + .../editor/blocks/MindmapMiniMap.tsx | 3 + .../editor/blocks/MindmapNavigator.tsx | 3 + .../editor/blocks/MindmapSidebar.tsx | 9 +- .../editor/blocks/MindmapSidebarTrigger.tsx | 3 + .../editor/blocks/MindmapToolbar.tsx | 7 +- .../editor/editor-host-config.test.ts | 42 +- .../components/editor/editor-host-config.ts | 19 +- .../src/components/editor/editor-host.tsx | 13 - .../leptos-tiptap-island-editor-host.test.tsx | 13 + .../leptos-tiptap-island-editor-host.tsx | 25 +- .../tiptap-content-converter.test.ts | 50 + .../lib/documents/tiptap-content-converter.ts | 36 +- .../mindmap/leptos-mindmap-adapter.test.ts | 92 + .../src/lib/mindmap/leptos-mindmap-adapter.ts | 169 ++ .../lib/mindmap/mindmap-action-map.test.ts | 85 + .../src/lib/mindmap/mindmap-action-map.ts | 141 ++ .../lib/mindmap/mindmap-command-diff.test.ts | 168 ++ .../src/lib/mindmap/mindmap-command-diff.ts | 292 ++++ .../src/lib/mindmap/mindmap-projection.ts | 175 +- .../src/lib/mindmap/mindmap-ui-schema.test.ts | 99 ++ .../src/lib/mindmap/mindmap-ui-schema.ts | 192 +++ .../src/lib/mindmap/mindmap-ui-state.test.ts | 38 + .../src/lib/mindmap/mindmap-ui-state.ts | 64 + .../mindmap/simple-mind-map-bridge.test.ts | 109 ++ .../src/lib/mindmap/simple-mind-map-bridge.ts | 310 ++++ .../src/types/simple-mind-map-internals.d.ts | 4 + 51 files changed, 8669 insertions(+), 1313 deletions(-) create mode 100644 design/06-mindmap/process/6-mindmap-leptos-adapter-reference-notes-v1.md create mode 100644 design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md rename {wolai-frontend => recycle/wolai-frontend}/src/app/dev/mindmap/page.tsx (100%) create mode 100644 rust/crates/mnote-web/src/routes/mindmap_api.rs create mode 100644 scripts/task166-mindmap-phase6-block-smoke.js create mode 100644 wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.test.ts create mode 100644 wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts create mode 100644 wolai-frontend/src/lib/mindmap/mindmap-action-map.test.ts create mode 100644 wolai-frontend/src/lib/mindmap/mindmap-action-map.ts create mode 100644 wolai-frontend/src/lib/mindmap/mindmap-command-diff.test.ts create mode 100644 wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts create mode 100644 wolai-frontend/src/lib/mindmap/mindmap-ui-schema.test.ts create mode 100644 wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts create mode 100644 wolai-frontend/src/lib/mindmap/mindmap-ui-state.test.ts create mode 100644 wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts create mode 100644 wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.test.ts create mode 100644 wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts diff --git a/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md b/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md index be611830..df231ead 100644 --- a/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md +++ b/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md @@ -1,934 +1,436 @@ -# 6 [process] Mindmap Kernel Phase 6:降级为 Projection / Editor v1 +# 6 [process] Mindmap Kernel Phase 6:leptos-mindmap Projection Editor v1 -> 更新时间:2026-04-22 +> 更新时间:2026-05-10 +> +> 当前口径:`leptos-mindmap` +> +> 旧稿归档: +> - `/mnt/Data1T/mnote/recycle/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.rust-native-2026-05-10.md` > > 当前主线依据: > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` > - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` > -> 历史参考: -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md` -> - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/ai-first-rust-block-editor-baseline-v1.md` +> 参考实现: +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/lx-doc/mind-map` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/kmind-plugin` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/siyuan-kmind-plugin` ## 1. 文档目的 -这份文档用于重写 `Kernel Phase 6` 的落地口径。 +这份文档重新修正 Mindmap Phase 6 的落地口径。 -这里仍沿用 `Phase 6` 命名,是为了保留与旧阶段拆分的一致性;当前执行依据应以 `design/01-05-current-priority-overview.md` 和相关现行主线文档为准,不再以 `1-1-tree-first-graph-kernel-checklist-v2.md` 作为唯一推进入口。 +旧稿把 Phase 6 定义为 `Rust / Leptos 原生 Projection Editor`,并把 `simple-mind-map` 排除在本轮 runtime 主链之外。该口径在长期上最干净,但在当前阶段等同于用 Rust 重写 `lx-doc/mind-map` / `simple-mind-map` 的编辑器和布局引擎,短期会继续产出可验证但不可用的导图纵切。 -这里回答的不是: +当前口径改为: -- “要不要马上把导图 UI 全量重写成 Rust” +> **leptos-mindmap:Rust kernel 持有导图事实源与命令语义,`simple-mind-map` / KMind-like runtime 作为 Leptos 文档页中的编辑器 adapter。** -而是: +这份文档回答的是: -> **在当前仓库代码现实下,Mindmap 如何从独立对象中心,降级成 `tree-first graph kernel` 的一种 projection / editor。** - -这份文档必须同时满足三件事: - -- 服从 `tree-first graph kernel` 主线 -- 对齐当前仓库里的真实实现,而不是抽象设想 -- 给出可迁移、可双写、可分阶段切流的方案 - ---- +> **如何在不放弃 tree-first graph kernel 的前提下,先交付接近 KMind 可用度的导图编辑器。** ## 2. 先给结论 -结论固定如下: +Phase 6 不再以 Rust 自绘导图 renderer 为第一目标。 -> **Mindmap 后续必须进行 Rust 内核化重构,但重构重点不是先替换 `simple-mind-map` 画布,而是先把“导图真相、导图命令、导图 projection、AI 工具入口”切到 Rust kernel。** +Phase 6 的主目标是建立: -换句话说: +```text +kernel truth + -> mindmap.kernel_projection.v1 + -> mindmap.simple_mind_map_scene.v1 + -> leptos-mindmap editor island + -> simple-mind-map / KMind-like runtime + -> adapter diff / command bridge + -> kernel command +``` -- **必须 Rust 化的部分** - - 导图事实源 - - 导图命令层 - - 导图 projection 层 - - AI / CLI 直接调用的导图工具层 -- **不必第一阶段 Rust 化的部分** - - 具体画布渲染器 - - 工具栏、缩略图、拖拽动画、局部 DOM 细节 +其中: -因此: +- Rust kernel 是事实源。 +- `simple-mind-map` 是显示与交互 runtime。 +- `leptos-mindmap` 是二者之间的稳定 adapter。 +- `lx-doc/mind-map` 和 KMind 插件是行为、视觉、工具栏、侧栏和主题的参考实现。 +- 旧 React `MindmapBlock.tsx` 只能作为 legacy/compat 参考,不作为当前文档页主链。 +- TypeScript NodeView / bridge 只允许作为 `leptos-tiptap` 与 `simple-mind-map` 的薄兼容层;toolbar、sidebar、navigator、context menu 等 UI shell 的长期归属应逐步迁到 Leptos/Rust 组件,避免继续扩散 React/Vue/TS UI 运行时。 -> **Phase 6 的正确目标不是“把导图换个前端库”,而是“让导图不再以 `simple-mind-map` JSON 为系统真相”。** +这不是“直接嵌入 `lx-doc/mind-map` 保存 blob”,也不是“立即 Rust 重写 `lx-doc/mind-map`”。它是中间路线: ---- +> **Rust 内核 + lx-doc/KMind 级显示编辑体验。** -## 3. 当前代码现实 +## 3. 三条路线的定位 -当前导图主链已经部分接入 Rust runtime,但整体仍然是“前端重交互壳 + blob 持久化 + Rust compat 工具”的形态,还不是 kernel truth。 +### 3.1 Rust 重写 lx-doc/mind-map -### 3.1 前端主壳仍然由 `simple-mind-map` 驱动 +这是长期最干净的路线。 -当前主组件: +优点: -- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx` +- kernel、projection、layout、hit-test、selection、history 都能完全统一。 +- AI/CLI 可直接操作导图内核,不被前端库字段绑架。 +- 长期不受 `simple-mind-map` 事件粒度、数据格式和插件边界限制。 -现状特征: +问题: -- 直接加载 `simple-mind-map` 与大量插件 -- 同时承担内嵌块、独立页、全屏态、工具栏、缩略图、右键菜单、图片处理、导入导出 -- 直接调用: - - `mindmap.setData(...)` - - `mindmap.getData(...)` - - `mindmap.execCommand(...)` -- 直接请求: - - `fetch(/api/mindmap/${docId}/${mindmapId})` +- 实际上要重写的是 `simple-mind-map` 引擎,而不只是 `lx-doc/mind-map` 的 Vue UI。 +- 需要自研布局、节点测量、连线、概要、外框、关联线、拖拽、快捷键、导入导出、主题、MiniMap、富文本等完整编辑器能力。 +- 短期会牺牲可用体验。 -这说明当前导图编辑器仍然以画布库数据结构为第一现场。 +结论: -### 3.2 当前所谓 projection 仍然是前端摘要层,不是 kernel projection +> 保留为长期方向和后续 Rust-native engine 路线,但不再作为 Phase 6 的主验收。 -当前文件: +### 3.2 Rust 内核 + lx-doc/mind-map 显示 -- `/mnt/Data1T/mnote/wolai-frontend/src/lib/mindmap/mindmap-projection.ts` +这是当前推荐路线,即 `leptos-mindmap`。 -当前 `buildMindmapProjection(...)` 的本质是: +优点: -- 接受一份 raw mindmap blob -- 做 `simple-mind-map` 兼容归一化 -- 输出一个前端摘要对象 -- 同时保留 `data` 原始树 +- 能快速获得接近 KMind 的可用编辑体验。 +- Rust kernel 仍然持有事实源、projection 和 command。 +- 可以逐步把 `simple-mind-map` 字段提升为 kernel-native 语义。 +- 与 `leptos-tiptap` 的定位一致:前端编辑器是 runtime,不是系统事实源。 -这意味着当前 projection 还是: +问题: -- **blob 的摘要** +- `simple-mind-map` 的事件不一定足够细,需要 adapter diff。 +- 高级能力短期可能需要 `compat_payload`。 +- 必须严格防止回退成“保存整棵前端 blob”。 -而不是: +结论: -- **kernel subtree / graph 的正式投影** +> 作为 Phase 6 主线。 -### 3.3 当前持久化仍然是整棵导图 blob +### 3.3 直接嵌入 lx-doc/mind-map -当前持久化主链: +这是最快路线,但不作为主线。 -- `/mnt/Data1T/mnote/wolai-frontend/convex/mindmaps.ts` -- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts` +优点: -现状特征: +- 最快得到完整 UI。 +- 迁移成本看起来最低。 -- `mindmaps.get` 返回整棵 `data` -- `mindmaps.put` 写回整棵 `data` -- `mindmaps` 表持有的是一份完整导图 JSON -- 独立页服务端入口仍然走: - - `mindmaps.get` - - 然后在前端侧 `buildMindmapProjection(...)` +问题: -这说明当前“导图真相”依然是: +- `lx-doc/mind-map` 会重新成为事实源。 +- 长期仍保存 `simple-mind-map` blob。 +- kernel、AI、Page Aggregate、tree realtime 只能在外围补偿。 -- **可直接存取的一整棵导图 blob** +结论: -而不是: +> 只可作为短期 demo 或对照,不作为 mnote 主线。 -- **kernel node / edge / subtree** +## 4. leptos-mindmap 的边界 -### 3.4 Rust runtime 已经介入,但仍是 compat mindmap 树语义 +`leptos-mindmap` 是一个编辑器 adapter,不是新的事实源。 -当前 Rust 侧相关实现: +它负责: -- `/mnt/Data1T/mnote/rust/crates/core-protocol/src/mindmap.rs` -- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/src/lib.rs` +- 在 `leptos-tiptap` NodeView 中挂载导图编辑器。 +- 把 kernel projection 转成 `simple-mind-map` 可消费的数据。 +- 初始化并管理 `simple-mind-map` runtime 和必要插件。 +- 监听导图数据与视图变化。 +- 把变化转成 kernel command 或 adapter diff。 +- 渲染 KMind-like toolbar、右侧样式栏、底部工具和错误状态。 +- 承载 floating overlay canvas shell,并逐步把 UI shell 从临时 TypeScript DOM 渲染迁回 Leptos/Rust 组件边界。 -已存在的事实: +它不负责: -- Rust 侧已经有: - - `MindmapTreeNode` - - `MindmapOp` - - `mindmap_get` - - `mindmap_get_subtree` - - `mindmap_put` - - `mindmap_apply_ops` - - `mindmap_outline_to_mindmap` -- `bridge-runtime` 已能在 Rust 内部应用 `MindmapOp` +- 长期保存整棵导图 blob。 +- 绕过 kernel command 直接把前端 runtime 数据写成 canonical truth。 +- 在 UI 层创造第二套树真相、顺序真相或引用边真相。 +- 把 `window.__mindmapInstance`、React mount 点或占位 DOM 作为完成判定。 +- 把 TypeScript NodeView 扩写成长期 UI 框架;TS 只保留 tiptap / runtime bridge、动态加载、事件转发和最小 DOM mount 职责。 -但这些能力当前本质上仍然是: +## 5. 数据分层 -- 对一棵 `MindmapTreeNode` JSON 树做读取、修改、返回 +### 5.1 kernel truth -不是: +长期事实源应表达: -- 对 `KernelNode` / `KernelEdge` / `KernelSubtree` 做正式操作 +- `Mindmap` +- `MindmapNode` +- parent / child order +- collapsed state +- summary / generalization +- associative line +- node style +- link / reference edge +- theme / layout / view state -### 3.5 Kernel 协议已具备导图进入统一内核的入口 +这些字段应属于 kernel 或 kernel projection,不能只存在于 `simple-mind-map` blob。 -当前 kernel 类型: +### 5.2 mindmap.kernel_projection.v1 -- `/mnt/Data1T/mnote/rust/crates/core-protocol/src/kernel.rs` +这是稳定投影,供 AI、CLI、独立页、文档页和后续 Rust-native renderer 使用。 -已经有: +它应包含: -- `KernelNodeType::Mindmap` -- `KernelNodeType::MindmapNode` -- `KernelProjectionKind::Mindmap` - -这说明协议层已经承认: - -> **导图应该属于统一 kernel,而不是永远停留在专用 blob 协议。** - -问题不在方向,而在主线尚未切换完成。 - ---- - -## 4. 当前架构问题 - -如果继续维持当前模式,会有四类问题。 - -### 4.1 导图真相仍被视图库数据结构绑架 - -现在真正被保存、被读取、被回写的是: - -- `simple-mind-map` 兼容树 - -这会导致: - -- 领域模型被 UI 库字段形状反向约束 -- 导图语义无法稳定进入 kernel -- 不同视图无法共享统一对象真相 - -### 4.2 AI 仍然不是直接操作 kernel - -虽然已经有 `mindmap_apply_ops` 等工具,但它们当前语义仍然是: - -- 取出一棵树 -- 在 runtime 里改树 -- 返回新树 - -这仍然不是: - -- 直接创建 `mindmap_node` -- 直接移动 subtree -- 直接挂接 reference edge - -因此 AI 还没有真正“直接写导图内核”。 - -### 4.3 导图还没有进入统一 projection 家族 - -当前 Sidebar / 页面树 / 文件树 正在往 kernel projection 收敛。 - -但导图仍然主要是: - -- 专用 route -- 专用 blob -- 专用前端大组件 - -这会让导图继续成为一个旁路系统。 - -### 4.4 前端壳过重,迁移边界不清 - -`MindmapBlock.tsx` 当前同时承担: - -- 读取 -- 兼容转换 -- 渲染 -- 编辑 -- 保存 -- 资产替换 -- 导入导出 -- 页面模式切换 - -这意味着: - -> **只要导图真相还留在这里,Phase 6 就不会真正成立。** - ---- - -## 5. Phase 6 的正确目标 - -Phase 6 的正确目标固定为: - -> **让 Mindmap 从“独立对象中心 + blob 真相 + 前端重壳”降级成“kernel subtree / graph 的一种 projection 与 editor”。** - -再收口成一句: - -> **Mindmap 不是对象真相层,Mindmap 只是树/图真相的一种空间化编辑视图。** - -这意味着: - -- 导图不能再拥有第二份对象真相 -- 导图不能再以整棵 blob 作为长期 canonical model -- 导图编辑器只能操作 kernel -- 导图 route 只能消费 kernel projection - ---- - -## 6. 目标分层 - -长期建议把导图域拆成四层。 - -### 6.1 Kernel Truth - -这一层只存系统真相。 - -建议对象: - -- `mindmap` - - 作为导图根容器节点 -- `mindmap_node` - - 作为导图树节点 -- `reference edge` - - 作为节点到页面、附件、PDF anchor、summary、ai_note 的横向关系 - -这层只负责: - -- 节点存在性 -- 父子结构 -- sibling 顺序 -- 节点稳定 ID -- 节点元信息 -- 引用边 -- 审计与版本 - -### 6.2 Mindmap Projection Layer - -这一层负责把 kernel truth 投影为导图视图可消费的数据。 - -长期至少应有两类 projection: - -- `mindmap_preview` - - 给文档内嵌卡片、轻量预览使用 -- `mindmap_editor` - - 给导图独立页、沉浸编辑器使用 - -这层输出的应是: - -- 稳定 node id -- parent / child 关系 -- sibling order -- depth -- child count -- 引用摘要 -- capability flags -- 视图提示信息 - -而不是直接把前端控件内部状态回吐出去。 - -### 6.3 Editor Adapter Layer - -这一层负责: - -- 把 kernel projection 转成 `simple-mind-map` 当前所需的数据形状 -- 把画布交互翻译成 kernel command - -这一层是 compat adapter,不是事实源。 - -### 6.4 View Shell - -这一层只负责: - -- 画布 -- 工具栏 -- 右键菜单 -- 缩略图 -- 全屏壳 -- 交互状态 - -这里可以继续用 `simple-mind-map` 过渡,但不能再持有对象真相。 - ---- - -## 7. 哪些语义必须进入 kernel - -下面这些语义必须进入 kernel,而不是继续停留在 `simple-mind-map` blob 中。 - -### 7.1 节点级稳定语义 - -- `mindmap` 根节点 -- `mindmap_node` 子节点 -- `title / text` -- `note` -- `hyperlink` -- `refs` -- `collapsed` -- sibling 排序键 -- 节点归档/删除状态 - -### 7.2 树语义 - -- 创建子节点 -- 创建同级节点 -- 重命名 -- 移动 subtree -- 重排顺序 -- 删除 subtree - -### 7.3 图语义 - -- 节点引用页面 -- 节点引用块 -- 节点引用附件 -- 节点引用 PDF 页码/anchor -- AI 生成节点引用证据节点 - -### 7.4 审计语义 - -- command id -- trace id -- actor id -- revision / version - ---- - -## 8. 哪些语义不应进入 kernel - -下面这些内容不应进入 kernel truth。 - -- 当前缩放比例 -- 当前视口位置 -- 当前选中节点 -- minimap 展开状态 -- 文本编辑框 DOM 状态 -- 鼠标拖拽中的临时态 -- 纯视图级动画状态 -- `simple-mind-map` 内部 history 栈 -- 仅服务当前画布库的缓存字段 - -这些内容属于: - -- editor session state -- view shell state - -不是 kernel truth。 - ---- - -## 9. 命令层重构口径 - -当前 `mindmap_apply_ops` 和 `mindmap_put` 不能再继续被当作长期真相接口。 - -### 9.1 `mindmap_put` - -长期应降级为: - -- 导入整图 -- 替换快照 -- 迁移回放 -- 故障恢复 - -不应再作为常规编辑主路径。 - -### 9.2 `mindmap_apply_ops` - -长期应保留,但语义要改。 - -它应变成: - -- **compat facade** - -内部行为应是: - -- 把 `MindmapOp` 翻译成 kernel command 序列 - -例如: - -- `addChild` - - `kernel.node.create` -- `addSiblingAfter` - - `kernel.node.create` + sibling order 调整 -- `updateText` - - `kernel.node.update` -- `setHyperlink` - - `kernel.node.update` -- `setRefs` - - `kernel.edge.attach` / `kernel.edge.detach` -- `deleteNode` - - `kernel.subtree.delete` 或 `kernel.node.archive` - -也就是说: - -> **`mindmap_apply_ops` 可以继续存在,但它不能继续直接修改一棵 compat 树。** - -### 9.3 新的正式命令面 - -Phase 6 后导图应以 kernel command 为正式写面: - -- `create_node` -- `update_node` -- `move_subtree` -- `reorder_siblings` -- `archive_node` -- `restore_node` -- `attach_edge` -- `detach_edge` - -如果 kernel 当前缺少某些命令,就应在 Phase 6 补入,而不是继续把缺口留给前端 blob。 - ---- - -## 10. Projection 重构口径 - -当前导图 route 仍然主要走: - -- `mindmaps.get` - -然后前端: - -- `buildMindmapProjection(...)` - -这条链必须调整。 - -### 10.1 正式读路径 - -长期正式读路径应是: - -- `kernel.project_view` - - projection=`mindmap` - -而不是: - -- `mindmaps.get` - - 返回 raw blob - -### 10.2 Projection 输出要求 - -导图 projection 输出至少应包括: - -- `projection_id` -- `root_node_id` -- `items` +- `schema` +- `documentId` +- `mindmapId` +- `rootNodeId` +- `revision` +- `nodes` - `edges` -- `depth` -- `sort_key` -- `expand_hint` -- `capability_flags` -- `resource_meta` +- `summaries` +- `associativeLines` +- `layout` +- `theme` +- `view` +- `capabilities` +- `source` -### 10.3 前端 adapter 的角色 +### 5.3 mindmap.simple_mind_map_scene.v1 -前端可以继续存在一个: +这是 adapter projection,只服务 `simple-mind-map` runtime。 -- `projection -> simple-mind-map` adapter +它应包含: -但这层只能是 view adapter。 +- `root` +- `layout` +- `theme` +- `themeConfig` +- `view` +- `config` +- `compatPayload` +- `projectionRevision` +- `kernelRevision` -它不能再同时承担: +注意:这层可以长得像 `simple-mind-map`,但它不是 canonical truth。 -- canonical model -- 持久化模型 -- AI 写入模型 +### 5.4 compat_payload ---- +短期无法语义化的字段进入 `compat_payload`。 -## 11. AI / CLI 口径 +允许先放入: -如果目标是“AI 能直接编写思维导图,而不是外挂组件”,那 Phase 6 的 AI 路线必须明确。 +- 高级节点样式 +- 图片 +- 公式 +- 附件 +- 富文本片段 +- 第三方插件私有字段 +- KMind 专有扩展 -### 11.1 AI 读取导图 +但每个字段必须有提升策略,不能无限期成为黑箱。 -AI 应读取: +## 6. 命令桥接 -- kernel subtree -- mindmap projection -- node refs / evidence +Phase 6 最小命令集: -而不是只读取一棵视图库 JSON。 +- `mindmap.node.update_text` +- `mindmap.node.insert_child` +- `mindmap.node.insert_sibling_after` +- `mindmap.node.delete` +- `mindmap.node.move` +- `mindmap.view.patch` +- `mindmap.layout.set` +- `mindmap.theme.set` +- `mindmap.summary.set` +- `mindmap.associative_line.upsert` -### 11.2 AI 修改导图 +桥接方式分两级。 -AI 应直接发 kernel command,或通过 compat facade 发命令。 +### 6.1 直接命令 -正确路径应是: +如果 toolbar 或快捷键能明确知道用户意图,直接发 kernel command。 -- AI 意图 -- tool / command plan -- kernel command -- projection 刷新 +示例: -不是: +- 点击“子节点” -> `mindmap.node.insert_child` +- 点击“同级节点” -> `mindmap.node.insert_sibling_after` +- 节点文本提交 -> `mindmap.node.update_text` +- 删除节点 -> `mindmap.node.delete` -- AI 输出一整棵导图 JSON -- 再整体覆盖保存 +### 6.2 adapter diff -### 11.3 AI 生成导图 +如果 `simple-mind-map` 只给出 `data_change`,则对比上一份 adapter projection 和当前 runtime data。 -`mindmap_outline_to_mindmap` 这类能力可以保留,但输出应优先写入: +diff 输出: -- `mindmap` -- `mindmap_node` -- `reference edge` +- 可识别变化转 kernel command。 +- 暂不可识别变化转 `compat_payload.patch`,并打上 `source="adapter-diff"`。 -而不是先生成一棵孤立 blob 再把它塞进存储。 +禁止把整棵 runtime data 直接作为 canonical success。 ---- +## 7. UI 对标范围 -## 12. 与当前代码对应的重构任务 +第一阶段对标 `image copy 60.png` 和 KMind/lx-doc 体验,但不追全量功能。 -### 12.1 Rust 协议层 +必须有: -需要重构或补充: +- 顶部工具栏:撤销、重做、编辑节点、同级节点、子节点、删除节点、标签、超链接、备注、图片、图标、概要、关联线、公式、格式刷、导入、导出。 +- 中央画布:真实节点、连线、pan、zoom、选择、双击编辑、键盘基本操作。 +- 右侧触发栏:节点样式、导图样式、主题、结构、大纲。 +- 底部工具:节点数/字数、回到根节点、搜索、缩放百分比、全屏或只读入口。 +- 错误状态:projection 获取失败、command 失败、adapter diff 失败要可定位。 -- `/mnt/Data1T/mnote/rust/crates/core-protocol/src/kernel.rs` -- `/mnt/Data1T/mnote/rust/crates/core-protocol/src/mindmap.rs` +可延期: -建议动作: +- 完整主题设计器。 +- 多根节点。 +- 思源块悬浮预览。 +- 镜像块。 +- MOC 模式。 +- PDF 标注跳转。 +- 全量 XMind/Freemind 导入导出。 -- 保留 `MindmapOp` 作为 compat DTO -- 不再把 `MindmapTreeNode` 当长期 canonical model -- 为导图补齐正式 projection / command 契约 +## 8. 代码落点建议 -### 12.2 Rust runtime +### 8.1 Rust 协议层 -需要重构: +- `rust/crates/core-protocol/src/mindmap.rs` + - 定义 kernel projection DTO。 + - 定义 adapter projection DTO。 + - 定义 command DTO。 + - 标记旧 `MindmapTreeNode` / `MindmapOp` 为 compat。 -- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/src/lib.rs` +### 8.2 Rust runtime 层 -建议动作: +- `rust/crates/bridge-runtime/src/lib.rs` + - 构建 `mindmap.kernel_projection.v1`。 + - 构建 `mindmap.simple_mind_map_scene.v1`。 + - 执行 kernel command。 + - 维护 compat snapshot。 -- 增加正式 `mindmap` projection builder -- 把 `mindmap_apply_ops` 改成 command translator -- 让 `mindmaps.get` 逐步降级为 compat query -- 新增或补齐导图相关 kernel command +### 8.3 Rust Web 层 -### 12.3 前端 mindmap adapter +- `rust/crates/mnote-web/` + - 暴露 projection / command route。 + - 保持 3000 是前端公开入口。 + - 不在 Rust Web 内联第三方编辑器业务逻辑。 -需要重构: +### 8.4 Leptos / Tiptap 层 -- `/mnt/Data1T/mnote/wolai-frontend/src/lib/mindmap/mindmap-projection.ts` -- `/mnt/Data1T/mnote/wolai-frontend/src/lib/mindmap/mindmapOps.ts` +- `rust/spikes/leptos-tiptap-spike/src/lib.rs` + - 提供 mindmap NodeView。 + - 挂载 `leptos-mindmap` island。 + - Tiptap 节点只保存 `mindmapId/rootNodeId/projectionVersion` 等引用。 -建议动作: +### 8.5 前端 adapter 层 -- `mindmap-projection.ts` - - 从“blob 摘要器”转成“kernel projection adapter” -- `mindmapOps.ts` - - 从“本地真相修改器”降级为 compat / fallback 层 +建议新增或收口到: -### 12.4 前端视图壳 +- `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts` +- `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts` +- `wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts` -需要重构: +实际目录可随现有 runtime 打包方式调整,但职责必须清楚。 -- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx` -- `/mnt/Data1T/mnote/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx` -- `/mnt/Data1T/mnote/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/mindmap-page-client.tsx` +## 9. 验收标准 -建议动作: +Phase 6 新验收不再要求 Rust 自绘 scene。 -- 将读取从 `mindmaps.get` 切到 kernel projection -- 将写入从 `getData()/POST whole blob` 切到命令流 -- 将 `MindmapBlock.tsx` 缩成 view shell + adapter +必须验证: -### 12.5 持久化与兼容层 +- 文档页 `/` 插入 mindmap block 后进入 `leptos-tiptap` NodeView。 +- NodeView 挂载 `leptos-mindmap`。 +- 画布由 `simple-mind-map` / KMind-like runtime 渲染真实节点与连线。 +- 默认骨架可见 `KMIND`、`二级节点`、两个 `分支主题` 和 `概要`。 +- 修改节点文本后通过 kernel command 回写。 +- reload 后从 kernel projection 恢复修改结果。 +- smoke 输出结构化 JSON 和截图。 +- 失败时能定位到 projection、adapter init、runtime render、diff、command 或 reload 阶段。 -需要重构: +明确不通过: -- `/mnt/Data1T/mnote/wolai-frontend/convex/mindmaps.ts` -- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts` +- 只看到标题或空容器。 +- 只断言 React mount 点。 +- 只断言 `window.__mindmapInstance`。 +- 只保存整棵 blob 且无 kernel command。 +- 把 `simple-mind-map` runtime data 当成唯一事实源。 -建议动作: +## 10. 2026-05-10 现实复核:当前实现差距 -- 迁移期允许双写 -- 长期让 `mindmaps` 表退到 compat snapshot / import-export 层 -- 正式真相切到 kernel backing store +当前代码已打通 `mindmap.simple_mind_map_scene.get`、`mindmap.command.apply`、reload 后文本保留和最小 smoke,但这只能证明 projection / command 的纵切闭环。 ---- +它还不能证明 `leptos-mindmap` 已完成。 -## 13. 迁移分期 +已确认的差距: -### 13.1 Phase 6-A:冻结边界 - -目标: - -- 停止在前端和 compat blob 上继续追加长期业务语义 - -详细 checklist: - -- [ ] 冻结口径: - - 明确 `MindmapTreeNode` 只作为 compat DTO - - 明确 `mindmaps` blob 只作为过渡存储,不再新增长期业务语义 - - 明确 `simple-mind-map` 只作为 renderer / editor adapter,不再定义系统真相 -- [ ] 协议边界盘点: - - 盘点 `/mnt/Data1T/mnote/rust/crates/core-protocol/src/mindmap.rs` 中哪些字段属于 compat 语义 - - 盘点 `/mnt/Data1T/mnote/rust/crates/core-protocol/src/kernel.rs` 中已有 `mindmap` / `mindmap_node` / `projection` 能力 - - 列出 Phase 6 后必须新增的 kernel command / projection 契约缺口 -- [ ] 前端边界盘点: - - 盘点 `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx` 中哪些逻辑属于真相层 - - 将这些逻辑标记为后续待迁移: - - 读主链 - - 写主链 - - 本地摘要 - - 引用回写 - - 资产 URL 反写 - - 明确哪些逻辑继续保留在 view shell: - - 画布渲染 - - 右键菜单 - - 工具栏 - - 缩略图 - - 全屏壳 -- [ ] 持久化边界盘点: - - 盘点 `/mnt/Data1T/mnote/wolai-frontend/convex/mindmaps.ts` 的现有字段与索引 - - 盘点 `/mnt/Data1T/mnote/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts` 的现有读写语义 - - 明确哪些接口后续降级为 compat: - - `mindmaps.get` - - `mindmaps.put` - - `mindmaps.delete` - - `mindmaps.restore` -- [ ] AI / CLI 边界盘点: - - 盘点 `mindmap_get` / `mindmap_get_subtree` / `mindmap_put` / `mindmap_apply_ops` - - 标注哪些工具后续保留为 facade,哪些应切到 kernel command - - 明确 AI 不再以“整图 JSON 覆盖”作为长期主路径 -- [ ] 文档口径冻结: - - 当前文档作为 Phase 6 主文档继续维护 - - checklist / architecture / 后续任务拆分不再把导图描述为独立事实源 - - 新增导图相关设计时默认引用本文件,而不是继续围绕 `simple-mind-map` 数据结构展开 -- [ ] 代码约束冻结: - - 在未完成 Phase 6-B 之前,不再给 `MindmapTreeNode` 增加新的长期业务字段 - - 在未完成 Phase 6-C 之前,不再新增新的“整图读取后本地改树再整体提交”的主路径 - - 在未完成 Phase 6-D 之前,不再新增绕过 kernel 的 AI 写入链路 - -出阶段判定: - -- [ ] 新导图语义默认先判断是否落 kernel,而不是先落前端 blob -- [ ] `MindmapBlock.tsx` 不再继续承担新的长期对象真相职责 -- [ ] 团队已统一接受: - - compat blob 不是长期真相 - - `simple-mind-map` 不是对象中心 - - Phase 6 后正式主线是 kernel truth + projection + adapter - -### 13.2 Phase 6-B:先切读路径 - -目标: - -- 独立页与内嵌预览先读取正式 kernel projection - -详细 checklist: - -- [ ] 定义 projection 家族: - - 定义 `mindmap_preview` projection - - 定义 `mindmap_editor` projection - - 明确两者共享的稳定字段: - - `projection_id` - - `root_node_id` - - `items` - - `edges` - - `capability_flags` - - `resource_meta` -- [ ] 定义 projection item 结构: - - 稳定 node id - - `parent_id` - - `sort_key` - - `depth` - - `child_count` - - `title/text` - - `collapsed` - - `refs summary` - - `style hint` -- [ ] Rust runtime 补 projection builder: - - 在 `/mnt/Data1T/mnote/rust/crates/bridge-runtime/src/lib.rs` 中增加正式 `mindmap` projection builder - - 让 `kernel.project_view` 可返回 `KernelProjectionKind::Mindmap` - - 明确 preview 与 editor 的输出差异,不再直接返回 compat 整树 -- [ ] 前端 adapter 改造: - - 将 `/mnt/Data1T/mnote/wolai-frontend/src/lib/mindmap/mindmap-projection.ts` 从“blob 摘要器”改为“kernel projection adapter” - - 把 adapter 输出限定为 `simple-mind-map` 所需最小数据形状 - - 不再让 adapter 同时承担 canonical model 职责 -- [ ] 独立页读路径切换: - - `/mnt/Data1T/mnote/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx` - 改为优先读取 kernel projection - - SSR 初始数据不再以 `mindmaps.get` raw blob 为主 - - `mindmap-page-client.tsx` 仅接收 projection / adapter 输出 -- [ ] 文档内嵌预览切换: - - 内嵌卡片优先消费 `mindmap_preview` - - 预览态不再默认依赖整棵 blob - - 轻量预览和沉浸编辑使用不同 projection,避免主编辑壳过早挂载 -- [ ] 兼容回退策略: - - kernel projection 不可用时,可临时回退到 compat `mindmaps.get` - - 回退路径必须显式标记为 compat/fallback - - 回退逻辑不得反向成为新主路径 -- [ ] 可观测性与追踪: - - projection 响应带 `request_id` / `trace_id` - - 前端记录当前页面命中的 projection 来源: - - kernel - - compat fallback - - 为后续切流留出观测点 -- [ ] 测试与验收: - - 增加 runtime 单测:`mindmap_preview` / `mindmap_editor` 输出稳定 - - 增加 adapter 单测:projection -> `simple-mind-map` data - - 增加独立页 smoke:首屏读 projection 成功 - - 增加内嵌态 smoke:文档页不再依赖整图 blob 才能显示摘要 - -出阶段判定: - -- [ ] 独立页正式主读链已经是 kernel projection -- [ ] 文档内嵌预览正式主读链已经是 preview projection -- [ ] compat `mindmaps.get` 只作为 fallback,而不是默认主链 -- [ ] 前端已有清晰的 `projection -> adapter -> renderer` 三层边界 - -### 13.3 Phase 6-C:再切写路径 - -目标: - -- 常规编辑改为命令流 - -详细 checklist: - -- [ ] 明确正式写面: - - `create_node` - - `update_node` - - `move_subtree` - - `reorder_siblings` - - `archive_node` - - `restore_node` - - `attach_edge` - - `detach_edge` -- [ ] 补齐 kernel command 缺口: - - 若当前 kernel 缺少 sibling reorder 命令,需补齐 - - 若当前 kernel 缺少 subtree archive/delete 语义,需补齐 - - 若当前 kernel 缺少导图节点 refs 的 attach/detach 语义,需补齐 -- [ ] compat `MindmapOp` 到 kernel command 的翻译表落地: - - `addChild` - - `addSiblingAfter` - - `updateText` - - `setHyperlink` - - `setRefs` - - `appendNote` - - `deleteNode` - - 为每个 op 指定唯一的 kernel command 映射与错误语义 -- [ ] Rust runtime 改造: - - `/mnt/Data1T/mnote/rust/crates/bridge-runtime/src/lib.rs` - 中的 `mindmap_apply_ops` 改为 command translator - - 不再直接对 compat 树做 canonical 修改 - - 执行完成后返回: - - command 执行结果 - - 最新 projection 或 refresh token - - trace / audit 信息 -- [ ] 前端保存链改造: - - `MindmapBlock.tsx` 中工具栏操作不再默认走 `getData()/POST whole blob` - - 节点编辑、移动、删除、引用更新都发命令,而不是整体回传 snapshot - - 本地只保留短暂 optimistic state,不再作为长期真相 -- [ ] 导图 route 改造: - - `/api/mindmap/[docId]/[mindmapId]` - 的 `POST` 从常规编辑主入口降级 - - 新增或切换到专用 command route / command envelope - - `mindmap_put` 只保留: - - 导入 - - 替换快照 - - 恢复 - - 迁移回放 -- [ ] 双写与迁移策略: - - 迁移期允许 kernel truth + compat snapshot 双写 - - 双写失败时要有清晰告警,不得静默漂移 - - 明确哪一侧是主真相,哪一侧只是镜像 -- [ ] 冲突与审计: - - 所有命令返回 revision / version - - 写入链保留 `command_id` / `request_id` / `trace_id` - - 并发冲突时优先按 kernel command 冲突规则处理,而不是前端最后一次整图覆盖 -- [ ] 测试与验收: - - runtime 单测:各类 `MindmapOp` 均正确翻译为 kernel command - - route 单测:常规编辑不再依赖整图 `put` - - UI smoke:增删改拖拽节点后可稳定刷新 projection - - 回归测试:导入/恢复仍可通过 `mindmap_put` 正常工作 - -出阶段判定: - -- [ ] 常规编辑链路已经以 kernel command 为主 -- [ ] `mindmap_put` 已从常规编辑主链退出 -- [ ] 前端不再依赖 `getData()` 作为长期提交真相 -- [ ] compat snapshot 即使保留,也只是双写镜像或导入导出格式 - -### 13.4 Phase 6-D:统一 AI / CLI - -目标: - -- AI / CLI 改为直接操作 kernel truth - -详细 checklist: - -- [ ] AI 工具口径统一: - - 明确 AI 读导图优先读取 kernel projection / subtree - - 明确 AI 写导图优先发送 kernel command - - 明确 AI 不再以“输出完整 mindmap JSON 并整体覆盖”作为主模式 -- [ ] compat tool 重构: - - `mindmap_get` - 降级为 compat read facade - - `mindmap_get_subtree` - 降级为 compat read facade - - `mindmap_apply_ops` - 降级为 compat write facade - - `mindmap_put` - 降级为导入/恢复工具 -- [ ] 新 kernel-aware tool 面补齐: - - 面向 node / subtree / edge 的导图工具定义 - - 工具参数默认使用: - - `nodeId` - - `rootNodeId` - - `workspaceId` - - `pageId` - - `edgeType` - - 避免继续以 compat `uid + whole tree` 为中心 -- [ ] AI host/runtime 接缝改造: - - 导图 agent route 优先走 kernel-aware tool - - `mindmap_outline_to_mindmap` 的输出优先落 kernel truth - - AI 修改后的刷新结果优先返回 projection,而不是 raw blob -- [ ] CLI 改造: - - CLI 新增或切换到导图 kernel command 子命令 - - 现有 `mindmap get/put/op` 标记 compat/legacy 语义 - - CLI smoke 优先验证 kernel command 与 projection 输出 -- [ ] 权限与审计: - - AI / CLI 导图写入保留 actor / trace / command id - - 区分: - - 用户直接编辑 - - AI 代理修改 - - 导入/恢复 - - 确保后续 bridge log / audit 能区分来源 -- [ ] 结果返回规范: - - AI / CLI 执行导图写命令后,默认返回: - - command 执行结果 - - 受影响 node / edge - - 最新 projection 摘要 - - 非必要不再回传整棵 compat 树 -- [ ] 迁移与兼容: - - 迁移期 compat tools 仍可保留 - - 但默认优先级必须低于 kernel-aware tools - - 新增 AI 能力时不得再优先扩写 compat blob 工具 -- [ ] 测试与验收: - - AI tool 单测:至少一条创建节点、修改节点、挂接 refs 的链路走 kernel command - - CLI smoke:至少一条真实导图操作链不依赖整图覆盖 - - 回归测试:compat tool 仍可用于迁移和紧急 fallback - -出阶段判定: - -- [ ] AI 已能直接创建、修改、移动导图节点与引用边 -- [ ] CLI 已能直接操作导图 kernel truth -- [ ] compat tools 只剩 facade / import-export / fallback 职责 -- [ ] 导图 AI / CLI 主链已经和 Sidebar / 页面树 / 阅读页一样,正式回到统一 kernel command / projection 体系 - -### 13.5 Phase 6-E:压缩旧壳 - -目标: - -- 把 `MindmapBlock.tsx` 收缩为可替换 view shell - -要求: - -- 真相、命令、projection 已完全外移 -- 前端只剩渲染与交互适配 - ---- - -## 14. 完成判定 - -只有同时满足下面几条,才能说 `Kernel Phase 6` 完成。 - -- [ ] 导图正式事实源已经进入 kernel node / edge / subtree -- [ ] 导图独立页读取的是 kernel projection,而不是 `mindmaps.get` raw blob -- [ ] 文档内嵌导图读取的是 preview projection,而不是前端直接拼整棵树 -- [ ] 常规编辑操作已经回写 kernel command,而不是 `POST` 整棵 `getData()` -- [ ] AI / CLI 可以直接创建、修改、移动导图节点,而不依赖整图覆盖 -- [ ] `simple-mind-map` 已经退到 renderer / adapter 层 -- [ ] `mindmaps` blob 存储已降级为 compat snapshot 或导入导出用途 - ---- - -## 15. 非目标 - -这阶段不以这些事情为主目标: - -- 立即把整个导图画布改写成 Rust 前端 -- 立即替换全部导图 UI 细节 -- 把 `simple-mind-map` 的所有样式字段完整提升为 kernel 语义 -- 在第一阶段就清空全部历史兼容接口 - -Phase 6 的重点只有一个: - -> **先把导图真相、导图命令、导图 projection 从前端 blob 体系里拔出来,正式并入 `tree-first graph kernel`。** +- `leptos-tiptap` NodeView 当前仍在 `tiptap_paragraph.ts` 内手写 DOM。 +- 当前所谓 `simple-mind-map-runtime` 只是一个带 `data-runtime="simple-mind-map"` 的容器,不是 `new MindMap(...)` 创建的真实实例。 +- 节点是手写 ``).join("");ee.innerHTML=`
`,ae.innerHTML=`${be}
\u7ECF\u5178\u4E3B\u9898 \xB7 \u903B\u8F91\u7ED3\u6784
`,at.innerHTML=`runtime \u8282\u70B9100%`},ds=()=>{let z=ye.querySelector('[data-testid="mindmap-schema-context-menu"]');if(!v){z?.remove();return}z||(z=document.createElement("div"),z.className="mnote-mindmap-context-menu",z.dataset.testid="mindmap-schema-context-menu",z.dataset.uiSource="typescript-nodeview-bridge",ye.append(z)),z.dataset.contextMenuKind=b,z.style.left=`${Math.max(8,E)}px`,z.style.top=`${Math.max(8,S)}px`;let V=Fr.contextMenuItems.filter(J=>b==="node"||!J.requiresNode);z.innerHTML=V.map(J=>``).join("")},qr=()=>{hi(),F.dataset.stage="loading",G.dataset.runtimeEngine="loading",G.innerHTML='
\u6B63\u5728\u52A0\u8F7D\u5BFC\u56FE runtime...
',st()},ei=(z,V)=>{let{mindmapId:J}=nt();hi(),F.dataset.stage=z,G.dataset.runtimeEngine="error",G.innerHTML=`
${hs(z)}${hs(J)}${hs(V)}
`,st()},nr=async(z,V)=>{if(F.dataset.commandCount=String(z.length),F.dataset.commandHasProjection=a?"true":"false",F.dataset.commandFailedAction="",F.dataset.commandFailedMessage="",z.length===0||!a){F.dataset.commandStatus="skipped";return}let{mindmapId:J}=nt(),de=Rm();if(!de){ei("command_failed",`mindmapId=${J}; documentId_missing`);return}F.dataset.commandStatus="pending";try{let be=await kv({documentId:de,mindmapId:J,commands:z,projectionRevision:a.kernelRevision});if(I)return;if(await za(be),V){let ge=eB(be.root,V)?V:tB(be.root)??$v(be,nt().rootNodeId);l=ge,h=ge,st()}d=null,F.dataset.commandStatus="success"}catch(be){let ge=be instanceof Error?be.message:String(be);F.dataset.commandStatus="failed",F.dataset.commandFailedAction=F.dataset.lastSchemaAction??"",F.dataset.commandFailedMessage=ge,ei("command_failed",`mindmapId=${J}; ${ge}`)}},cs=async z=>{if(!a)return;let{mindmapId:V}=nt(),J=Rm();if(!J){ei("command_failed",`mindmapId=${V}; documentId_missing`);return}F.dataset.viewCommandStatus="pending";try{if(await zm({documentId:J,mindmapId:V,commands:[Dv(V,z)],projectionRevision:a.kernelRevision}),I)return;a.view=z,F.dataset.viewCommandStatus="success"}catch(de){ei("command_failed",`mindmapId=${V}; ${de instanceof Error?de.message:String(de)}`)}},An=z=>{let V=QO(z);V&&(m=V,F.dataset.lastViewPatch=JSON.stringify(V),g!==null&&window.clearTimeout(g),g=window.setTimeout(()=>{g=null;let J=m;m=null,J&&cs(J)},350))},Dd=z=>{if(s){if(z==="centerRoot"&&s.instance.renderer?.setRootNodeCenter?.(),z==="zoomOut"&&s.instance.view?.narrow?.(),z==="zoomIn"&&s.instance.view?.enlarge?.(),z==="fitView"&&s.instance.view?.reset?.(),z==="search"){F.dataset.searchStatus="ready",F.dataset.lastSearchQuery="";return}if(z==="expandCollapse"){s.instance.renderer?.toggleActiveExpand?.(),F.dataset.contextMenuActionStatus="success",F.dataset.lastContextMenuAction=z,st();return}if(z==="copyNodeText"){let V=a?wt(a.root):null,J=V&&typeof V.data?.text=="string"?V.data.text:"";F.dataset.copiedNodeText=J,F.dataset.contextMenuActionStatus="success",F.dataset.lastContextMenuAction=z;return}z==="readonly"&&(c=!c),An(s.getSnapshot()),st()}},_a=(z,V={})=>{let J=Ol(z);if(!J||Im({activeNodeId:l,readonly:c,runtimeCapabilities:["runtimeCommand","kernelCommand","compatPatch","localView"]}).disabledActions[z])return;if(F.dataset.lastSchemaAction=z,J.target==="localView"){Dd(z);return}let be=JO(z),ge=h??l,et=nt().mindmapId,Ge=Hv({actionId:z,mindmapId:et,activeNodeId:ge,node:be,value:V.value??(z==="editNode"?Dm(a):!0),source:V.source,runtimeRevision:a?.kernelRevision??null,kernelRevision:a?.kernelRevision??null}),Bl=rB(V.compatPath,ge);if(Bl&&(Ge={command:Dl({mindmapId:et,path:Bl,value:V.value??!0,source:V.source??"sidebar",actionId:z,runtimeRevision:a?.kernelRevision??null,kernelRevision:a?.kernelRevision??null})}),F.dataset.lastSchemaCommand=Ge?.command?JSON.stringify(Ge.command):"",F.dataset.lastSchemaRuntimeCommand=Ge?.runtimeCommand??"",!!Ge){if(J.target==="runtimeCommand"&&Ge.runtimeCommand){if(Ge.command){let Bm=be?.uid??(z==="deleteNode"?nt().rootNodeId:ge);nr([Ge.command],Bm);return}s?.execCommand(Ge.runtimeCommand);return}Ge.command&&nr([Ge.command])}},La=z=>{if(z.type==="node_active"){let J=l;l=ZO(z.args[0])??l,(!h||h===J)&&(h=l),st();return}if(z.type==="view_data_change"||z.type==="scale"||z.type==="translate"){F.dataset.lastViewEvent=z.type,An(z.snapshot??s?.getSnapshot());return}if(z.type!=="data_change"||!a||!z.snapshot)return;let V=Pv({mindmapId:nt().mindmapId,previous:o??a.root,next:z.snapshot});o=z.snapshot,nr([...V.commands,...V.compatPatches])},za=async z=>{let{mindmapId:V,rootNodeId:J}=nt();if(s?.destroy(),Om(V,null),a=z,l=$v(z,J),h=h??l,o=z.root,G.dataset.runtimeEngine="simple-mind-map",G.dataset.runtimeReady="false",G.replaceChildren(),st(),s=await Cv({el:G,projection:z,mode:"edit",runtimeOptions:{fit:!0,mousewheelAction:"zoom",enableFreeDrag:!0,enableCtrlKeyNodeSelection:!0,useLeftKeySelectionRightKeyDrag:!0,createNewNodeBehavior:"activeOnly"},onEvent:La}),I){s.destroy();return}G.dataset.runtimeReady="true",F.dataset.stage="ready",Om(V,s),st()},Ia=async()=>{let z=++n,{mindmapId:V,rootNodeId:J}=nt(),de=Rm();if(!de){ei("projection_load_failed",`mindmapId=${V}; documentId_missing`);return}qr();try{if(z!==n)return;let be=await Lm({documentId:de,mindmapId:V,endpoint:`/api/mindmap/${encodeURIComponent(de)}/${encodeURIComponent(V)}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get&rootNodeId=${encodeURIComponent(J)}`});if(z!==n||I)return;await za(be)}catch(be){ei("projection_load_failed",`mindmapId=${V}; ${be instanceof Error?be.message:String(be)}`)}};return D.addEventListener("pointerdown",z=>z.stopPropagation()),D.addEventListener("mousedown",z=>z.stopPropagation()),D.addEventListener("mnote:mindmap-shell:action",z=>{let V=z.detail,J=V?.actionId;if(J==="search"){F.dataset.searchStatus="ready",F.dataset.lastSearchQuery=typeof V?.value=="string"?V.value:"";return}J&&_a(J,V)}),D.addEventListener("mnote:mindmap-shell:minimap",z=>{let V=z.detail;x=typeof V?.open=="boolean"?V.open:!x,F.dataset.minimapOpen=x?"true":"false",st()}),D.addEventListener("mnote:mindmap-shell:panel",z=>{let V=z.detail;V?.panelId&&(f=V.panelId,st())}),D.addEventListener("input",z=>{if(!O)return;let V=z.target;V instanceof HTMLInputElement&&V.dataset.testid==="mindmap-command-text-input"&&(d=V.value)}),D.addEventListener("click",z=>{z.stopPropagation();let V=z.target;if(!(V instanceof HTMLElement))return;let J=V.closest("button");if(J instanceof HTMLButtonElement){if(!O){z.preventDefault();let de=J.dataset.mindmapContextActionId;if(de){v=!1,ds(),_a(de);return}if(J.closest('[data-testid="mindmap-rust-shell"]'))return;let be=J.dataset.mindmapActionId;be&&_a(be);let ge=J.dataset.mindmapSidebarPanelId;ge&&(f=ge,st());return}if(J.dataset.testid==="mindmap-command-update-text"){z.preventDefault();let de=$.querySelector('[data-testid="mindmap-command-text-input"]'),be=d??(de instanceof HTMLInputElement?de.value:Dm(a));F.dataset.lastCommandText=be,nr([{type:"updateText",mindmapId:nt().mindmapId,nodeId:l??nt().rootNodeId,text:be}])}J.dataset.testid==="mindmap-command-add-child"&&(z.preventDefault(),s?.execCommand("INSERT_CHILD_NODE")),J.dataset.testid==="mindmap-command-add-sibling-after"&&(z.preventDefault(),s?.execCommand("INSERT_NODE")),J.dataset.testid==="mindmap-command-delete-node"&&(z.preventDefault(),s?.execCommand("DELETE_NODE")),J.dataset.testid==="mindmap-toolbar-undo"&&(z.preventDefault(),s?.execCommand("BACK")),J.dataset.testid==="mindmap-toolbar-redo"&&(z.preventDefault(),s?.execCommand("FORWARD")),J.dataset.testid==="mindmap-toolbar-summary"&&(z.preventDefault(),s?.execCommand("ADD_GENERALIZATION")),J.dataset.testid==="mindmap-bottom-center-root"&&(z.preventDefault(),s?.instance.renderer?.setRootNodeCenter?.(),An(s?.getSnapshot())),J.dataset.testid==="mindmap-bottom-zoom-out"&&(z.preventDefault(),s?.instance.view?.narrow?.(),An(s?.getSnapshot())),J.dataset.testid==="mindmap-bottom-zoom-in"&&(z.preventDefault(),s?.instance.view?.enlarge?.(),An(s?.getSnapshot()))}}),D.addEventListener("contextmenu",z=>{if(O)return;z.preventDefault(),z.stopPropagation();let V=z.target;b=V instanceof Element&&V.closest(".smm-node")?"node":"canvas",E=z.offsetX,S=z.offsetY,v=!0,F.dataset.contextMenuKind=b,ds()},!0),re.append(G),O?(Z.append(G,ae),F.append(ee,Z,at)):(ye.append(W),Z.append(re,ye),F.append(Z)),$.append(F),D.append($),Ia(),{dom:D,update(z){return z.type.name!==r.type.name||(r=z,r.attrs.mnoteBlockType!=="mindmap")?!1:(Ia(),!0)},stopEvent:()=>!0,ignoreMutation:()=>!0,destroy(){I=!0,g!==null&&window.clearTimeout(g);let{mindmapId:z}=nt();Om(z,null),s?.destroy(),Jt(),s=null}}}}function oB(){return({node:i})=>{let e=i,t=document.createElement("p"),r=()=>{let n=e.attrs.blockId;typeof n=="string"&&n.length>0?(t.dataset.blockId=n,t.id=n):(t.removeAttribute("data-block-id"),t.removeAttribute("id"))};return r(),{dom:t,contentDOM:t,update(n){return n.type.name!==e.type.name||n.attrs.mnoteBlockType==="mindmap"?!1:(e=n,r(),!0)}}}}var lB=qm.extend({addAttributes(){return{...this.parent?.(),blockId:{default:null,parseHTML:i=>i.getAttribute("data-block-id"),renderHTML:i=>XO(i.blockId)},mnoteBlockType:{default:null,parseHTML:i=>i.getAttribute("data-mnote-block-type"),renderHTML:i=>KO(i)},mindmapId:{default:null,parseHTML:i=>i.getAttribute("data-mnote-mindmap-id"),renderHTML:()=>({})},rootNodeId:{default:null,parseHTML:i=>i.getAttribute("data-mnote-root-node-id"),renderHTML:()=>({})},projectionVersion:{default:null,parseHTML:i=>Number(i.getAttribute("data-mnote-projection-version")??1),renderHTML:()=>({})}}},addNodeView(){let i=aB(this.editor),e=oB();return({node:t,getPos:r})=>t.attrs.mnoteBlockType==="mindmap"?i({node:t,getPos:r}):e({node:t})}}),hB={name:"paragraph",create:()=>lB,commands:{set_paragraph:i=>i.chain().focus().setParagraph().run()},selection_keys:["paragraph"],selection_state:i=>({paragraph:i.isActive("paragraph")})};function xie(){$m(hB)}export{xie as register_paragraph}; +/*! Bundled license information: + +@svgdotjs/svg.js/dist/svg.esm.js: + (*! + * @svgdotjs/svg.js - A lightweight library for manipulating and animating SVG. + * @version 3.2.0 + * https://svgjs.dev/ + * + * @copyright Wout Fierens + * @license MIT + * + * BUILT: Mon Jun 12 2023 10:34:51 GMT+0200 (Central European Summer Time) + *) + +lodash-es/lodash.js: + (** + * @license + * Lodash (Custom Build) + * Build: `lodash modularize exports="es" -o ./` + * Copyright OpenJS Foundation and other contributors + * Released under MIT license + * Based on Underscore.js 1.8.3 + * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + *) +*/ diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 5e1e2e3a..7b955e74 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -43,6 +43,9 @@ const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status"; const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection"; const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command"; const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height"; +const MINDMAP_SHELL_ACTION_EVENT: &str = "mnote:mindmap-shell:action"; +const MINDMAP_SHELL_PANEL_EVENT: &str = "mnote:mindmap-shell:panel"; +const MINDMAP_SHELL_MINIMAP_EVENT: &str = "mnote:mindmap-shell:minimap"; const STANDALONE_ROOT_ID: &str = "mnote-leptos-tiptap-standalone-root"; const E24_IMAGE_PLACEHOLDER_SRC: &str = "/api/editor/image-placeholder.svg"; const E24_IMAGE_PLACEHOLDER_ALT: &str = "E24 图片占位"; @@ -544,6 +547,502 @@ const SPIKE_STYLE: &str = r#" width: min(100%, 1120px); } +.mnote-mindmap-placeholder { + width: min(1280px, calc(100vw - 340px)); + margin: 10px 0; + margin-left: 50%; + transform: translateX(-50%); +} + +.mnote-mindmap-editor-root { + position: relative; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.32); + border-radius: 8px; + background: #ffffff; + color: #0f172a; +} + +.mnote-leptos-mindmap-shell { + position: relative; + height: min(72vh, 720px); + min-height: 560px; + display: flex; + flex-direction: column; +} + +.mnote-mindmap-command-toolbar, +.mnote-mindmap-bottom-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px; + border-bottom: 1px solid rgba(148, 163, 184, 0.26); + background: #ffffff; +} + +.mnote-mindmap-command-toolbar { + flex-wrap: wrap; +} + +.mnote-mindmap-schema-toolbar { + position: absolute; + left: 50%; + top: 22px; + z-index: 4; + width: max-content; + max-width: min(920px, calc(100% - 220px)); + box-sizing: border-box; + transform: translateX(-50%); + gap: 12px; + padding: 8px 14px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 6px; + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1); + backdrop-filter: blur(10px); + overflow-x: auto; + overflow-y: visible; + scrollbar-width: none; +} + +.mnote-mindmap-toolbar-primary, +.mnote-mindmap-toolbar-secondary { + display: flex; + align-items: center; + gap: 6px; + justify-content: center; + flex-wrap: wrap; +} + +.mnote-mindmap-toolbar-group { + display: inline-flex; + align-items: center; + gap: 4px; + padding-right: 10px; + border-right: 1px solid rgba(148, 163, 184, 0.22); +} + +.mnote-mindmap-toolbar-group:last-child { + border-right: 0; +} + +.mnote-mindmap-toolbar-group-label { + color: #64748b; + font-size: 11px; +} + +.mnote-mindmap-toolbar-primary input, +.mnote-mindmap-bottom-bar input { + height: 28px; + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 6px; + padding: 0 8px; + background: #fff; + color: #0f172a; + font-size: 12px; +} + +.mnote-mindmap-tool, +.mnote-mindmap-bottom-bar button, +.mnote-mindmap-side-tab { + height: 28px; + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 6px; + padding: 0 9px; + background: #fff; + color: #334155; + font-size: 12px; + cursor: pointer; +} + +.mnote-mindmap-schema-toolbar .mnote-mindmap-tool { + flex-direction: column; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 2px; + width: 48px; + height: 42px; + min-width: 48px; + padding: 3px 4px; + color: #475569; +} + +.mnote-mindmap-tool-icon { + min-width: 16px; + font-size: 15px; + line-height: 1; +} + +.mnote-mindmap-tool-label { + max-width: 44px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + line-height: 14px; +} + +.mnote-mindmap-tool:disabled, +.mnote-mindmap-bottom-bar button:disabled, +.mnote-mindmap-side-tab:disabled { + cursor: default; + opacity: 0.48; +} + +.mnote-mindmap-toolbar-more { + display: none; +} + +.mnote-mindmap-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr); + min-height: 460px; +} + +.mnote-mindmap-workspace[data-layout="floating-overlay"] { + position: relative; + display: block; + width: 100%; + height: 100%; + min-height: 560px; + overflow: hidden; + background: #f7f8fa; +} + +.mnote-mindmap-canvas-layer { + position: absolute; + inset: 0; + z-index: 1; +} + +.mnote-mindmap-overlay-layer { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; +} + +.mnote-mindmap-overlay-layer > * { + pointer-events: auto; +} + +.mnote-mindmap-rust-shell-mount, +.mnote-mindmap-rust-shell { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +.mnote-mindmap-rust-shell > * { + pointer-events: auto; +} + +.mnote-mindmap-workspace[data-debug-chrome="true"] { + grid-template-columns: minmax(0, 1fr) 132px; +} + +.mnote-mindmap-workspace[data-debug-chrome="false"] { + grid-template-columns: minmax(0, 1fr) 172px; +} + +.mnote-leptos-mindmap-runtime { + position: relative; + height: 460px; + min-height: 460px; + overflow: hidden; + background: #ffffff; +} + +.mnote-mindmap-workspace[data-layout="floating-overlay"] .mnote-leptos-mindmap-runtime { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + min-height: 100%; + background: #f7f8fa; +} + +.mnote-leptos-mindmap-runtime svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: auto; +} + +.mnote-leptos-mindmap-runtime .smm-node { + cursor: default; +} + +.mnote-mindmap-side-panel { + border-left: 1px solid rgba(148, 163, 184, 0.26); + background: #fff; + padding: 8px; +} + +.mnote-mindmap-schema-sidebar { + position: absolute; + right: 18px; + top: 50%; + z-index: 4; + width: 76px; + max-height: calc(100% - 130px); + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 2px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 6px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1); + backdrop-filter: blur(10px); +} + +.mnote-mindmap-side-tab { + width: 100%; + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 3px; + justify-content: center; + margin-bottom: 6px; + min-height: 56px; + padding: 5px 4px; + text-align: center; +} + +.mnote-mindmap-schema-sidebar .mnote-mindmap-side-tab { + margin-bottom: 0; +} + +.mnote-mindmap-side-icon { + width: 100%; + overflow: hidden; + color: #64748b; + font-size: 13px; + text-overflow: ellipsis; +} + +.mnote-mindmap-side-tab[data-active="true"] { + border-color: #2563eb; + background: #eff6ff; + color: #1d4ed8; +} + +.mnote-mindmap-side-body { + position: absolute; + right: 86px; + top: 0; + width: 148px; + margin-top: 8px; + color: #64748b; + font-size: 12px; + padding: 10px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 6px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1); + backdrop-filter: blur(10px); +} + +.mnote-mindmap-schema-sidebar .mnote-mindmap-side-body { + padding-top: 10px; + border-top: 1px solid rgba(15, 23, 42, 0.08); +} + +.mnote-mindmap-side-body strong, +.mnote-mindmap-side-body span { + display: block; +} + +.mnote-mindmap-side-swatches { + display: flex; + gap: 5px; + margin-top: 8px; +} + +.mnote-mindmap-side-swatches i { + width: 18px; + height: 18px; + border: 1px solid rgba(148, 163, 184, 0.38); + border-radius: 4px; + background: #f8fafc; +} + +.mnote-mindmap-side-options { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.mnote-mindmap-side-option { + min-height: 24px; + padding: 3px 7px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 5px; + background: #f8fafc; + color: #334155; + font-size: 11px; + line-height: 16px; +} + +.mnote-mindmap-side-option:hover { + border-color: rgba(37, 99, 235, 0.38); + background: #eff6ff; + color: #1d4ed8; +} + +.mnote-mindmap-side-outline { + max-height: 172px; + margin: 8px 0 0; + padding-left: 14px; + overflow: auto; + color: #334155; + font-size: 11px; + line-height: 1.45; +} + +.mnote-mindmap-side-outline:empty { + display: none; +} + +.mnote-mindmap-side-swatches i:nth-child(2) { + background: #eef2ff; +} + +.mnote-mindmap-side-swatches i:nth-child(3) { + background: #ecfeff; +} + +.mnote-mindmap-bottom-bar { + border-top: 1px solid rgba(148, 163, 184, 0.26); + border-bottom: 0; + font-size: 12px; + color: #475569; +} + +.mnote-mindmap-schema-navigator { + position: absolute; + right: 28px; + bottom: 22px; + z-index: 4; + justify-content: flex-end; + min-height: 40px; + padding: 0 12px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 6px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); + backdrop-filter: blur(10px); +} + +.mnote-mindmap-schema-navigator [data-testid="mindmap-schema-navigator-stats"] { + margin-right: auto; +} + +.mnote-mindmap-minimap { + position: absolute; + right: 28px; + bottom: 76px; + z-index: 4; + width: 180px; + height: 118px; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 6px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 2px 16px rgba(15, 23, 42, 0.08); + pointer-events: auto; +} + +.mnote-mindmap-minimap-map { + position: absolute; + inset: 12px; +} + +.mnote-mindmap-minimap-map span { + position: absolute; + display: block; + width: 42px; + height: 14px; + border-radius: 4px; + background: #dbeafe; +} + +.mnote-mindmap-minimap-map span:nth-child(1) { + left: 54px; + top: 8px; + background: #bfdbfe; +} + +.mnote-mindmap-minimap-map span:nth-child(2) { + left: 22px; + top: 52px; + background: #dcfce7; +} + +.mnote-mindmap-minimap-map span:nth-child(3) { + right: 22px; + top: 52px; + background: #fde68a; +} + +.mnote-mindmap-minimap-viewport { + position: absolute; + left: 38px; + top: 30px; + width: 104px; + height: 62px; + border: 2px solid rgba(37, 99, 235, 0.75); + border-radius: 5px; + background: rgba(37, 99, 235, 0.08); +} + +.mnote-mindmap-context-menu { + position: absolute; + z-index: 8; + min-width: 132px; + padding: 6px; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 6px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14); + pointer-events: auto; +} + +.mnote-mindmap-context-menu button { + display: block; + width: 100%; + min-height: 26px; + padding: 4px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: #334155; + font-size: 12px; + text-align: left; +} + +.mnote-mindmap-context-menu button:hover { + background: #eff6ff; + color: #1d4ed8; +} + +.mnote-mindmap-count { + position: absolute; + left: 28px; + bottom: 30px; + z-index: 4; + display: flex; + gap: 18px; + color: #64748b; + font-size: 12px; + pointer-events: auto; +} + .block-handle-shell { position: absolute; left: 12px; @@ -1998,6 +2497,41 @@ const SPIKE_STYLE: &str = r#" width: 100%; } + .mnote-mindmap-toolbar-secondary { + display: none; + } + + .mnote-mindmap-toolbar-more { + display: block; + } + + .mnote-mindmap-toolbar-more summary { + height: 28px; + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 6px; + padding: 5px 9px 0; + background: #fff; + color: #334155; + font-size: 12px; + cursor: pointer; + } + + .mnote-mindmap-toolbar-more > div { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + } + + .mnote-mindmap-workspace { + grid-template-columns: minmax(0, 1fr); + } + + .mnote-mindmap-side-panel { + border-left: 0; + border-top: 1px solid rgba(148, 163, 184, 0.26); + } + .block-handle-shell, .block-drop-indicator { display: none; @@ -2029,6 +2563,7 @@ enum SlashActionKind { CodeBlock, Divider, SimpleTable, + Mindmap, Image, UploadAttachment, Toc, @@ -2080,7 +2615,7 @@ const FOLDED_HEADING_ACTIONS: [FoldedHeadingAction; 4] = [ }, ]; -const SLASH_ACTIONS: [SlashAction; 21] = [ +const SLASH_ACTIONS: [SlashAction; 22] = [ SlashAction { kind: SlashActionKind::AiAssistant, id: "ai-assistant", @@ -2243,6 +2778,15 @@ const SLASH_ACTIONS: [SlashAction; 21] = [ description: "插入基础表格", shortcut: "/jdbg", }, + SlashAction { + kind: SlashActionKind::Mindmap, + id: "mindmap", + category: "进阶块列表", + icon: "⌘", + label: "思维导图", + description: "插入 kernel projection 导图块", + shortcut: "/dt", + }, SlashAction { kind: SlashActionKind::Image, id: "image", @@ -2659,6 +3203,10 @@ struct MountedRuntime { mount_handle: Box, } +struct MountedMindmapShell { + mount_handle: Box, +} + impl Drop for MountedRuntime { fn drop(&mut self) { for listener in &self.listeners { @@ -2675,6 +3223,7 @@ thread_local! { static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; static MOUNTED_HANDLES: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); static PENDING_RUNTIME_LISTENERS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); + static MOUNTED_MINDMAP_SHELLS: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); static NEXT_MOUNT_ID: Cell = const { Cell::new(1) }; } @@ -3034,6 +3583,402 @@ fn take_unmount_handle(id: u32) -> Option { MOUNTED_HANDLES.with(|registry| registry.borrow_mut().remove(&id)) } +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MindmapShellAction { + id: String, + label: String, + icon: String, + disabled: bool, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MindmapShellToolbarGroup { + id: String, + label: String, + actions: Vec, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MindmapShellSidebarPanel { + id: String, + label: String, + icon: String, + active: bool, + body_title: String, + body_caption: String, + #[serde(default)] + options: Vec, + #[serde(default)] + body_items: Vec, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MindmapShellSidebarOption { + id: String, + label: String, + action_id: Option, + value: Value, + compat_path: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MindmapShellNavigator { + word_count: u32, + node_count: u32, + zoom_percent: u32, + readonly: bool, + #[serde(default)] + minimap_open: bool, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MindmapShellOptions { + mindmap_id: String, + toolbar_groups: Vec, + sidebar_panels: Vec, + navigator: MindmapShellNavigator, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MindmapShellActionPayload<'a> { + action_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option<&'a Value>, + #[serde(skip_serializing_if = "Option::is_none")] + compat_path: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + source: Option<&'a str>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MindmapShellPanelPayload<'a> { + panel_id: &'a str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MindmapShellMinimapPayload { + open: bool, +} + +fn dispatch_mindmap_shell_event(target: &EventTarget, event_name: &'static str, payload: &T) +where + T: Serialize, +{ + let Ok(detail_value) = serde_wasm_bindgen::to_value(payload) else { + return; + }; + let init = CustomEventInit::new(); + init.set_detail(&detail_value); + init.set_bubbles(true); + if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) { + let _ = target.dispatch_event(&event); + } +} + +#[component] +fn MindmapShell(options: MindmapShellOptions, event_target: EventTarget) -> impl IntoView { + let toolbar_groups = options.toolbar_groups.clone(); + let sidebar_panels = options.sidebar_panels.clone(); + let navigator = options.navigator.clone(); + let minimap_open = navigator.minimap_open; + + let action_target = event_target.clone(); + let panel_target = event_target; + + view! { +
+
+
+ {toolbar_groups + .into_iter() + .map(|group| { + let group_id = group.id.clone(); + view! { +
+ {group.label} + {group.actions + .into_iter() + .map(|action| { + let action_id = action.id.clone(); + let action_label_title = action.label.clone(); + let action_label_aria = action.label.clone(); + let action_label_text = action.label.clone(); + let action_icon_text = action.icon.clone(); + let action_disabled = action.disabled; + let action_target = action_target.clone(); + view! { + + } + }) + .collect_view()} +
+ } + }) + .collect_view()} +
+
+ +
+ {format!("字数 {}", navigator.word_count)} + {format!("节点 {}", navigator.node_count)} +
+
+ + + + + {format!("{}%", navigator.zoom_percent)} + + +
+ {if minimap_open { + view! { +
+
+ + + +
+
+
+ }.into_any() + } else { + ().into_any() + }} +
+ } +} + +fn take_mindmap_shell_handle(id: u32) -> Option { + MOUNTED_MINDMAP_SHELLS.with(|registry| registry.borrow_mut().remove(&id)) +} + +#[wasm_bindgen] +pub fn mount_mindmap_shell(container: Element, options: JsValue) -> Result { + console_error_panic_hook::set_once(); + let options = serde_wasm_bindgen::from_value::(options)?; + let target = container + .dyn_into::() + .map_err(|_| JsValue::from_str("mindmap shell mount 目标必须是 HTML 元素"))?; + let mount_id = next_mount_id(); + let event_target: EventTarget = target.clone().into(); + let handle = mount_to(target, move || { + view! { + + } + }); + MOUNTED_MINDMAP_SHELLS.with(|registry| { + registry.borrow_mut().insert( + mount_id, + MountedMindmapShell { + mount_handle: Box::new(handle), + }, + ); + }); + Ok(mount_id) +} + +#[wasm_bindgen] +pub fn unmount_mindmap_shell(mount_id: u32) -> Result<(), JsValue> { + if take_mindmap_shell_handle(mount_id).is_some() { + Ok(()) + } else { + Err(JsValue::from_str("找不到对应的 mindmap shell 挂载句柄")) + } +} + #[wasm_bindgen] pub fn mount(container: Element, options: JsValue) -> Result { console_error_panic_hook::set_once(); @@ -3998,6 +4943,19 @@ fn paragraph_node(content: Vec) -> Value { node_with_attrs("paragraph", None, content) } +fn next_mindmap_id() -> String { + format!("mindmap_{}", js_sys::Date::now() as u64) +} + +fn mindmap_paragraph_node() -> Value { + let mut attrs = Map::new(); + attrs.insert("mnoteBlockType".to_string(), json!("mindmap")); + attrs.insert("mindmapId".to_string(), json!(next_mindmap_id())); + attrs.insert("rootNodeId".to_string(), json!("root")); + attrs.insert("projectionVersion".to_string(), json!(1)); + node_with_attrs("paragraph", Some(attrs), Vec::new()) +} + fn list_item_node(content: Vec) -> Value { node_with_attrs("listItem", None, vec![paragraph_node(content)]) } @@ -4137,6 +5095,7 @@ fn turn_into_block(node: &Value, action: SlashActionKind) -> Value { SlashActionKind::SimpleTable | SlashActionKind::UploadAttachment | SlashActionKind::Toc => { paragraph_node(inline) } + SlashActionKind::Mindmap => mindmap_paragraph_node(), SlashActionKind::Image => { let mut attrs = Map::new(); attrs.insert("src".to_string(), json!(E24_IMAGE_PLACEHOLDER_SRC)); @@ -5561,6 +6520,9 @@ fn run_slash_action( let _ = editor.focus(); editor.insert_table(4, 3, false) } + SlashActionKind::Mindmap => { + editor.insert_content(TiptapContent::json(mindmap_paragraph_node()), None) + } SlashActionKind::Image => { dispatch_editor_upload_request("image", "image/*")?; return Ok("已打开图片上传"); @@ -5595,6 +6557,7 @@ fn run_slash_action( SlashActionKind::CodeBlock => "已切到代码块", SlashActionKind::Divider => "已插入分割线", SlashActionKind::SimpleTable => "已插入简单表格", + SlashActionKind::Mindmap => "已插入思维导图", SlashActionKind::Image => "已打开图片上传", SlashActionKind::UploadAttachment => "已打开附件上传", SlashActionKind::Toc => "已插入页面目录", @@ -5644,6 +6607,10 @@ fn run_block_turn_into_action( ) .map(|_| "已把当前块转成分割线") .map_err(|err| format!("命令执行失败:{err}")), + SlashActionKind::Mindmap => editor + .insert_content_at(range, TiptapContent::json(mindmap_paragraph_node()), None) + .map(|_| "已把当前块转成思维导图") + .map_err(|err| format!("命令执行失败:{err}")), _ => { editor .set_text_selection(range) @@ -5944,6 +6911,14 @@ fn top_level_block_matches_action( SlashActionKind::CodeBlock => node_type == "codeBlock", SlashActionKind::Divider => node_type == "horizontalRule", SlashActionKind::SimpleTable => node_type == "table", + SlashActionKind::Mindmap => { + node_type == "paragraph" + && block + .get("attrs") + .and_then(|attrs| attrs.get("mnoteBlockType")) + .and_then(Value::as_str) + == Some("mindmap") + } SlashActionKind::Image => node_type == "image", SlashActionKind::UploadAttachment => false, SlashActionKind::Toc => node_type == "tocNode", @@ -9172,6 +10147,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { SlashActionKind::BulletList => "Ctrl+Shift+6", SlashActionKind::OrderedList => "Ctrl+Shift+7", SlashActionKind::CodeBlock => "Ctrl+Alt+-", + SlashActionKind::Mindmap => "/dt", _ => "", } }; @@ -9273,7 +10249,6 @@ fn App(mount_options: MountOptions) -> impl IntoView { - {move || { if block_folded_title_open.get() { view! { diff --git a/scripts/task166-mindmap-phase6-block-smoke.js b/scripts/task166-mindmap-phase6-block-smoke.js new file mode 100644 index 00000000..794a21fc --- /dev/null +++ b/scripts/task166-mindmap-phase6-block-smoke.js @@ -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); + }); +} diff --git a/wolai-frontend/scripts/build-leptos-tiptap-island.js b/wolai-frontend/scripts/build-leptos-tiptap-island.js index 9337db9a..4c72a55e 100644 --- a/wolai-frontend/scripts/build-leptos-tiptap-island.js +++ b/wolai-frontend/scripts/build-leptos-tiptap-island.js @@ -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() { diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts index e51ee61c..4dc6a62d 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts @@ -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<{ diff --git a/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx b/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx index 5f68af05..f4f3a15e 100644 --- a/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx +++ b/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx @@ -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 { +}): Promise { 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({ + const result = await executeRustBridgeQueryTransport({ 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 (
{ 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", + ); }); }); diff --git a/wolai-frontend/src/components/editor/editor-host-config.ts b/wolai-frontend/src/components/editor/editor-host-config.ts index 0af20d78..00fdc466 100644 --- a/wolai-frontend/src/components/editor/editor-host-config.ts +++ b/wolai-frontend/src/components/editor/editor-host-config.ts @@ -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 { diff --git a/wolai-frontend/src/components/editor/editor-host.tsx b/wolai-frontend/src/components/editor/editor-host.tsx index 1db4f7bf..e6ca9370 100644 --- a/wolai-frontend/src/components/editor/editor-host.tsx +++ b/wolai-frontend/src/components/editor/editor-host.tsx @@ -17,19 +17,6 @@ const LeptosTiptapIslandEditor = dynamic( }, ) as ComponentType; -const LeptosTiptapIframeDebugEditor = dynamic( - () => import("@/components/editor/leptos-tiptap-editor-host").then((mod) => mod.LeptosTiptapEditorHost), - { - ssr: false, - loading: () => ( -
编辑器加载中...
- ), - }, -) as ComponentType; - export function EditorHost(props: DocumentEditorHostProps) { - if (props.hostKind === "leptos_tiptap_iframe_debug") { - return ; - } return ; } diff --git a/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.test.tsx b/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.test.tsx index 7c99645b..7cbb7a96 100644 --- a/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.test.tsx +++ b/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.test.tsx @@ -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; diff --git a/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx b/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx index 1d9766cf..2d65616e 100644 --- a/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx +++ b/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx @@ -39,8 +39,21 @@ type IslandRuntimeModule = { default: (input?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module) => Promise; 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 = { 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 () => { diff --git a/wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts b/wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts index 3ab1f808..82233c5b 100644 --- a/wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts +++ b/wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts @@ -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: "", + }, + ]); + }); + }); diff --git a/wolai-frontend/src/lib/documents/tiptap-content-converter.ts b/wolai-frontend/src/lib/documents/tiptap-content-converter.ts index 6b16fbbe..f1709791 100644 --- a/wolai-frontend/src/lib/documents/tiptap-content-converter.ts +++ b/wolai-frontend/src/lib/documents/tiptap-content-converter.ts @@ -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), })); } diff --git a/wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.test.ts b/wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.test.ts new file mode 100644 index 00000000..8a228215 --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.test.ts @@ -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 }); + }); +}); diff --git a/wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts b/wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts new file mode 100644 index 00000000..b541097c --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts @@ -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; + 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; + 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 => + 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 => { + 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 => { + 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 => { + await executeMindmapCommandApply(input); + return requestMindmapAdapterProjection({ + documentId: input.documentId, + mindmapId: input.mindmapId, + endpoint: input.projectionEndpoint, + fetcher: input.fetcher, + }); +}; + +export const createLeptosMindmapAdapter = async ( + input: LeptosMindmapAdapterOptions, +): Promise => { + if (!isMindmapAdapterProjection(input.projection)) { + throw new Error("adapter_init_failed:invalid_projection"); + } + return createSimpleMindMapBridge(input); +}; diff --git a/wolai-frontend/src/lib/mindmap/mindmap-action-map.test.ts b/wolai-frontend/src/lib/mindmap/mindmap-action-map.test.ts new file mode 100644 index 00000000..429ab37c --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/mindmap-action-map.test.ts @@ -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); + }); +}); diff --git a/wolai-frontend/src/lib/mindmap/mindmap-action-map.ts b/wolai-frontend/src/lib/mindmap/mindmap-action-map.ts new file mode 100644 index 00000000..a9178cc0 --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/mindmap-action-map.ts @@ -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 {}; +}; diff --git a/wolai-frontend/src/lib/mindmap/mindmap-command-diff.test.ts b/wolai-frontend/src/lib/mindmap/mindmap-command-diff.test.ts new file mode 100644 index 00000000..cd643872 --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/mindmap-command-diff.test.ts @@ -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, + }); + }); +}); diff --git a/wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts b/wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts new file mode 100644 index 00000000..3f945a15 --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts @@ -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 } + | { 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, +): 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; + node: MindMapData; +}; + +const isRecord = (value: unknown): value is Record => + 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 => { + const root = canonicalizeMindmapData(input); + const index = new Map(); + 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 }; +}; diff --git a/wolai-frontend/src/lib/mindmap/mindmap-projection.ts b/wolai-frontend/src/lib/mindmap/mindmap-projection.ts index 543f280a..f0819393 100644 --- a/wolai-frontend/src/lib/mindmap/mindmap-projection.ts +++ b/wolai-frontend/src/lib/mindmap/mindmap-projection.ts @@ -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; 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, + }; +}; diff --git a/wolai-frontend/src/lib/mindmap/mindmap-ui-schema.test.ts b/wolai-frontend/src/lib/mindmap/mindmap-ui-schema.test.ts new file mode 100644 index 00000000..cb8b7720 --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/mindmap-ui-schema.test.ts @@ -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); + }); +}); diff --git a/wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts b/wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts new file mode 100644 index 00000000..8ded8f7d --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts @@ -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; + } +}; diff --git a/wolai-frontend/src/lib/mindmap/mindmap-ui-state.test.ts b/wolai-frontend/src/lib/mindmap/mindmap-ui-state.test.ts new file mode 100644 index 00000000..aab3b247 --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/mindmap-ui-state.test.ts @@ -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); + }); +}); diff --git a/wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts b/wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts new file mode 100644 index 00000000..70cb14a1 --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts @@ -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; +}; + +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(input.runtimeCapabilities) : null; + const disabledActions = {} as Record; + + 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, + }; +}; diff --git a/wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.test.ts b/wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.test.ts new file mode 100644 index 00000000..823e3bf4 --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.test.ts @@ -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"]]); + }); +}); diff --git a/wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts b/wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts new file mode 100644 index 00000000..addff179 --- /dev/null +++ b/wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts @@ -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) => 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 => + 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 => { + if (isRecord(value)) return value; + return {}; +}; + +const readViewData = (value: unknown): Record | 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; +}): Record => { + 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 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 => { + 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; + mode?: "edit" | "readonly"; + onEvent?: (event: SimpleMindMapBridgeEvent) => void; +}): Promise => { + 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, +}); diff --git a/wolai-frontend/src/types/simple-mind-map-internals.d.ts b/wolai-frontend/src/types/simple-mind-map-internals.d.ts index a5157ef4..4a753530 100644 --- a/wolai-frontend/src/types/simple-mind-map-internals.d.ts +++ b/wolai-frontend/src/types/simple-mind-map-internals.d.ts @@ -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;