20260513 mindmap优化01
This commit is contained in:
@@ -66,6 +66,17 @@
|
||||
- `design/old/` 下的历史废弃稿也统一按 `process/` 与 `done/` 分层,但标题继续标记 `[recycle]`。
|
||||
- `design/90-reference/` 只放参考资料,不参与 `process/done` 状态迁移。
|
||||
|
||||
## Bugs 目录规则
|
||||
|
||||
- `bugs/` 默认镜像 `design/` 的主线分类方式,按对应大类放置缺陷。
|
||||
- 每个大类继续按 `process/` 与 `done/` 分层:
|
||||
- `process/`:缺陷已确认存在,仍在修复或验证中
|
||||
- `done/`:缺陷已修复,且已有真实代码与验证证据
|
||||
- 新确认的缺陷先放 `process/`;修复完成后必须移动到同类目的 `done/`,不要在两个目录同时保留同一条缺陷。
|
||||
- 缺陷归类以真正 owner 和长期主线为准,不以表面症状命名:
|
||||
- `sidebar/topbar/breadcrumb/文档页壳/Wolai 体验对齐` 优先归到 `05-editor-mainline/`
|
||||
- `projection/row model/selection/focus/keyboard/DnD/tree command` 优先归到 `04-tree-domain/`
|
||||
|
||||
## 协作边界
|
||||
|
||||
- 仅修改与当前任务直接相关的文件。
|
||||
@@ -95,6 +106,22 @@
|
||||
- 影响主页入口、Sidebar、tree shell、文档页首屏时,优先补或复用 `scripts/task*-smoke.js` 这类 smoke 脚本。
|
||||
- 排查高 CPU / 高内存 / 卡顿时,先看是否存在首屏误走实验性 tree shell、compat fallback、重复请求、轮询或回链面板持续刷新,再看数据底座。
|
||||
|
||||
## mnote-tester 调用
|
||||
|
||||
- 当前固定网页测试员 profile 为 `mnote-tester`,Hermes profile 路径:`/home/lix/.hermes/profiles/mnote-tester`。
|
||||
- 默认优先通过别名调用:`/home/lix/.local/bin/mnote-tester`;等价命令是 `hermes -p mnote-tester`。
|
||||
- 需要让后续 agent 调它做真实浏览器测试时,优先使用 one-shot:
|
||||
- `mnote-tester --yolo -z "<测试任务>"`
|
||||
- 或 `hermes -p mnote-tester --yolo -z "<测试任务>"`
|
||||
- 交给 `mnote-tester` 的任务描述必须明确写出:
|
||||
- 测试目标页面或 URL
|
||||
- 是否要求真实登录
|
||||
- 是否要求新建 / 修改 / 删除页面或块
|
||||
- 测试数据前缀,例如 `TEST-HERMES-<timestamp>`
|
||||
- 是否要求发现 bug 后直接写入 `bugs/<category>/process/`
|
||||
- `mnote-tester` 允许在 `3000` 主页面做最小写入型测试,包括新建、重命名、编辑、移动、归档、恢复、删除页面,以及新建、修改、删除块/模块;但默认只能操作“本轮新建的测试数据”或用户明确指定的测试区域,不能动既有用户内容。
|
||||
- `mnote-tester` 的默认证据目录是 `/mnt/Data1T/mnote/tmp/hermes-tester/<run-id>/`;若发现 bug,应按 `/mnt/Data1T/mnote/bugs/README.md` 与本文件规则归类,并把缺陷记录写到对应 `bugs/<category>/process/`。
|
||||
- 若 `/auth`、快速登录、首屏路由或上游服务本身已阻塞,`mnote-tester` 应立即停止后续写入动作,先输出阻塞证据并按规则记录 blocker bug,不要伪造“新建/修改/删除已验证通过”。
|
||||
|
||||
## Wolai-aline 对标流程
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,117 @@
|
||||
# 4-25 [process][bug] 树命令新建/删除后强制刷新导致卡顿 v1
|
||||
|
||||
> 更新时间:2026-05-13
|
||||
>
|
||||
> 分类归属:
|
||||
> - `04-tree-domain/process`
|
||||
> - 关联缺陷:`bugs/05-editor-mainline/process/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md`
|
||||
>
|
||||
> 用户反馈:
|
||||
> - “删除页面和新建页面都很慢,不知道为什么这么卡。”
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
当前页面树/文件树的新建页面、删除页面等命令,在服务端命令成功后仍会触发整页刷新或完整树刷新。用户感知是:新建和删除并不是局部更新,而是卡一下、等整套页面壳或树壳重新加载。
|
||||
|
||||
这属于 `tree command -> projection update -> UI apply` 链路问题,应归到 `04-tree-domain`,而不是只作为编辑器 UI 问题处理。
|
||||
|
||||
## 2. 真实现象
|
||||
|
||||
用户反馈:
|
||||
|
||||
1. 新建页面慢。
|
||||
2. 删除页面慢。
|
||||
3. 卡顿与同轮 mindmap 文件树重复增长一起出现,怀疑树刷新链路被频繁触发。
|
||||
|
||||
期望结果:
|
||||
|
||||
1. `tree.node.create` 成功后,树 UI 局部插入新节点并进入重命名/导航状态。
|
||||
2. `tree.node.archive` 成功后,树 UI 局部移除节点或移动到回收站 projection。
|
||||
3. 只有 delta 无法应用、projection 缺失、SSE 断线或数据不一致时,才进入 resync/reload fallback。
|
||||
4. 普通新建/删除不应默认整页 reload。
|
||||
|
||||
## 3. 证据
|
||||
|
||||
### 3.1 Rust tree shell 有硬刷新路径
|
||||
|
||||
`rust/crates/mnote-web/src/routes/tree.rs:2976` 到 `:2986`:
|
||||
|
||||
- `scheduleRefresh()` 在 80ms 后执行 `window.location.reload()`。
|
||||
- 如果带 `renameRowId`,则通过 `window.location.assign(url.toString())` 重新加载。
|
||||
|
||||
调用点:
|
||||
|
||||
- 文件树删除:`rust/crates/mnote-web/src/routes/tree.rs:2791` 到 `:2824`
|
||||
- 新建页面:`rust/crates/mnote-web/src/routes/tree.rs:4309` 到 `:4340`
|
||||
- 重命名:`rust/crates/mnote-web/src/routes/tree.rs:4373` 到 `:4406`
|
||||
- 移动:`rust/crates/mnote-web/src/routes/tree.rs:4448` 到 `:4502`
|
||||
|
||||
这条路径会让 tree shell 命令体验明显慢于纯 delta / reducer 更新。
|
||||
|
||||
### 3.2 主页面已有 tree live controller,但命令后仍刷新
|
||||
|
||||
主页面已经监听 tree live:
|
||||
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3881` 到 `:3917` 处理 `tree:snapshot` / `tree:delta` / `tree:resync`
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3939` 到 `:4050` 启动 `/api/tree/events` EventSource
|
||||
|
||||
也就是说,命令完成后理论上可以由 delta/resync 更新 projection;当前硬 reload 与 live projection 机制重复。
|
||||
|
||||
### 3.3 命令本身已有 delta hint
|
||||
|
||||
Bridge runtime 对树命令生成 stream delta hint:
|
||||
|
||||
- `tree.node.create`:`rust/crates/bridge-runtime/src/lib.rs:9545` 到 `:9592`
|
||||
- `tree.node.archive`:`rust/crates/bridge-runtime/src/lib.rs:9655` 到 `:9702`
|
||||
|
||||
当前前端没有把这些结果作为默认局部更新来源,而是在 tree shell 中继续 reload。
|
||||
|
||||
### 3.4 React Sidebar 路径也存在重刷链
|
||||
|
||||
子代理只读调查发现 React Sidebar 路径也存在重刷:
|
||||
|
||||
- 新建页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:1799`、`:1847` 附近在 command 后 `await refreshTree()` 再跳转。
|
||||
- 删除页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:2148` 附近删除后等待 command、`refreshTree()`、广播文档变化/跳转。
|
||||
- Next API 的 `api/tree/commands` create/delete 还会执行 workspace/default scaffold、bridge mutation、artifact 记录等多段流程。
|
||||
|
||||
这说明卡顿不只来自单个 reload,而是命令后刷新链路偏重。
|
||||
|
||||
## 4. 当前判断
|
||||
|
||||
当前慢的核心不是“Convex 一定慢”,而是树命令执行后缺少轻量本地 apply:
|
||||
|
||||
1. 命令 result / delta hint 已经具备局部更新信息。
|
||||
2. UI 仍经常走 `refreshTree()`、`window.location.reload()` 或 full snapshot。
|
||||
3. `resync_required` 类事件会触发完整 workspace snapshot,结构性变化越多,越容易造成卡顿。
|
||||
|
||||
## 5. 建议验证
|
||||
|
||||
修复前先补一条计时 smoke:
|
||||
|
||||
1. 登录真实测试账号。
|
||||
2. 新建 `TEST-TREE-LATENCY-<timestamp>` 页面。
|
||||
3. 记录 `POST /api/tree/commands` 响应耗时。
|
||||
4. 记录下一次 `tree:delta` / `tree:resync` 到达耗时。
|
||||
5. 断言过程中是否发生 `window.location.reload()` 或顶层 navigation。
|
||||
6. 删除该测试页面,重复同样计时。
|
||||
7. 输出新建/删除从点击到 DOM 稳定的总耗时。
|
||||
|
||||
## 6. 建议修复方向
|
||||
|
||||
1. `tree.node.create` 成功后直接用 command result 插入本地节点,并只在后台等待 live delta 校准。
|
||||
2. `tree.node.archive` 成功后直接从当前 projection 移除节点,并只在后台等待 live delta 校准。
|
||||
3. `scheduleRefresh()` 改成显式 fallback,只有 reducer 无法应用时才调用。
|
||||
4. React Sidebar 的 `refreshTree()` 应去重、节流,并避免与 SSE/full snapshot 同时触发。
|
||||
5. smoke 增加无 reload 断言和耗时阈值。
|
||||
|
||||
## 7. 流转条件
|
||||
|
||||
当前状态:`process`
|
||||
|
||||
只有满足以下条件后才能移动到 `bugs/04-tree-domain/done/`:
|
||||
|
||||
1. [ ] 新建页面普通路径不再默认整页 reload。
|
||||
2. [ ] 删除页面普通路径不再默认整页 reload。
|
||||
3. [ ] command result / tree delta 能局部更新页面树与文件树 projection。
|
||||
4. [ ] fallback reload 只在明确异常条件下触发,并有可观测标记。
|
||||
5. [ ] smoke 记录新建/删除耗时,并通过无 reload 断言。
|
||||
@@ -0,0 +1,154 @@
|
||||
# 5-10 [done][bug] 文件树思维导图打开污染 index.md 单一真源 v1
|
||||
|
||||
> 更新时间:2026-05-13
|
||||
>
|
||||
> 分类归属:
|
||||
> - `05-editor-mainline/done`
|
||||
> - 涉及边界:`04-tree-domain/file_tree asset intent`、`03-rust-web/mindmap standalone shell`、`06-mindmap/projection editor`
|
||||
>
|
||||
> 关联文档:
|
||||
> - `/mnt/Data1T/mnote/design/10-review/README.md`
|
||||
> - `/mnt/Data1T/mnote/design/10-review/02-frontend-editor-tree-review.md`
|
||||
> - `/mnt/Data1T/mnote/design/10-review/04-secondary-domains-and-design-governance-review.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
当前文件树中的思维导图文件点击打开后,会进入 standalone `/mindmap/{documentId}/{mindmapId}` 壳。这个壳会构造一份只包含单个 mindmap block 的临时 editor bootstrap,而不是读取该页面真实的 `index.md` / Page Aggregate body。
|
||||
|
||||
结果是:用户从文件树打开思维导图、编辑、切换页面后,再点击同一页面的 `index.md`,可能看到只剩思维导图默认内容的页面体验。这里的核心问题不是单次保存失败,而是 `index.md` 页面正文与文件树里的 mindmap asset 没有明确收口到同一份页面真源。
|
||||
|
||||
## 2. 归类理由
|
||||
|
||||
本问题归到 `05-editor-mainline`,而不是只归到 `06-mindmap`,理由如下:
|
||||
|
||||
- 用户可见故障发生在文档页壳、文件树点击打开、主编辑区内容切换体验上。
|
||||
- 受影响对象是 `index.md` 页面正文与主编辑区当前内容,而不只是 mindmap runtime 内部编辑能力。
|
||||
- `design/10-review` 已明确当前 Page Aggregate 仍是“主链已切、单一真源收口未完”的状态,本问题正是页面正文、asset view 与 standalone shell 边界未闭合的具体缺陷。
|
||||
- `04-tree-domain` 需要定义 filetree asset row 的 open intent,但真正承载用户体验和页面真源一致性的 owner 仍是编辑主线。
|
||||
|
||||
## 3. 真实现象
|
||||
|
||||
用户复现结论:
|
||||
|
||||
1. 打开文件树中的思维导图文件,编辑后可以正常保存。
|
||||
2. 切换页面后,文件树中的思维导图仍能保存。
|
||||
3. 切换页面后点击文件树中的 `index.md`,主编辑区只显示思维导图默认内容,而不是该页面真实正文。
|
||||
|
||||
用户判断的根因方向:
|
||||
|
||||
1. 之前对文件树中的思维导图文件点击操作会对主页面产生干扰,应取消该干扰。
|
||||
2. 思维导图文件的点击打开应该在主编辑区弹出新页面或新 tab,类似 VS Code,而不是污染 `index.md`。
|
||||
3. 当前文件树 `index.md` 与其中的思维导图没有只持有一份真源,需要收口成单一来源。
|
||||
|
||||
## 4. 证据
|
||||
|
||||
设计审查证据:
|
||||
|
||||
- `design/10-review/README.md` 明确当前最需要收口的是 `Page Aggregate` 单一真源边界、tree realtime/live cache、side effect 一致性,以及 legacy/compat/fallback 退场边界。
|
||||
- `design/10-review/02-frontend-editor-tree-review.md` 的 F-01 指出 Page Aggregate 读链已 Rust-first,但客户端仍有本地 aggregate reducer 持有标题、正文、设置、子树快照等临时真相,页面域单一真源尚未闭环。
|
||||
- `design/10-review/04-secondary-domains-and-design-governance-review.md` 的 Mindmap 段落指出 standalone mindmap shell 已存在,但 compat 数据层仍承载导图数据,不应被描述为长期 canonical truth。
|
||||
|
||||
代码锚点:
|
||||
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:1100` 定义 `buildMindmapOpenPath(documentId, assetId)`,直接生成 `/mindmap/{documentId}/{assetId}`。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:1128` 到 `:1134` 的 `openConvexAssetFromFileTree` 在识别到 mindmap asset 后直接 `window.location.assign(buildMindmapOpenPath(documentId, assetId))`。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:1180` 到 `:1182` 将 `tree.asset.open` 统一交给 `openConvexAssetFromFileTree`。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3551` 到 `:3555` 文件树点击 `index/document/markdown` 时导航到文档页,但非文档 row 且有 `assetId` 时 dispatch `tree.asset.open`,mindmap asset 因而进入 standalone mindmap 路由。
|
||||
- `rust/crates/mnote-web/src/routes/mindmap_shell.rs:75` 到 `:104` standalone mindmap shell 构造临时 `editor_bootstrap.content`,内容只有一个 `mnoteBlockType: "mindmap"` 的 paragraph block。
|
||||
- `rust/crates/mnote-web/src/routes/mindmap_shell.rs:105` 到 `:123` standalone contract 标记为 `mnote.mindmap_shell.v1`,projection source 仍是 `compat-blob`。
|
||||
- `wolai-frontend/src/components/sidebar/sidebar-navigation.ts:21` 到 `:33` 的 React 侧 `buildSidebarMindmapOpenTarget` 在 `main` 模式下返回 `/documents/{documentId}`,只有 `sidebar` 模式才打开 `/mindmap/{documentId}/{mindmapId}`;这说明至少已有一条路径表达了“主编辑区不应直接进入 standalone mindmap shell”的倾向。
|
||||
|
||||
## 5. 当前判断
|
||||
|
||||
当前更像是三条路径混在一起:
|
||||
|
||||
1. `index.md` 应代表页面正文,并由 Page Aggregate body / page command 持有。
|
||||
2. 文件树中的 mindmap asset 应代表页面内某个 mindmap block 关联的资源视图或编辑入口。
|
||||
3. standalone `/mindmap/{doc}/{mindmap}` 是过渡兼容壳,应作为独立编辑页或调试/弹出入口,不应替代主编辑区的 `index.md` 真源。
|
||||
|
||||
因此,即使 mindmap asset 自己可以保存,也不能说明 `index.md` 与 mindmap block 已经同源。当前故障的关键是:打开 asset 的动作把用户带进了会伪造临时正文的 shell,导致页面主编辑体验看起来像 `index.md` 被替换成默认 mindmap。
|
||||
|
||||
## 6. 建议修复方向
|
||||
|
||||
### 阶段一:立即止血
|
||||
|
||||
1. 取消文件树 mindmap asset 点击直接 `window.location.assign('/mindmap/{doc}/{asset}')` 的主路径行为。
|
||||
2. 点击 `index.md` 必须始终打开真实 `/documents/{documentId}?treeView=filetree`,并读取真实 Page Aggregate body。
|
||||
3. mindmap asset 点击只能发出明确的 asset-open intent,不应改写当前页面正文、不应伪造 `index.md` 内容、不应让主编辑区误认为当前正文就是 standalone bootstrap。
|
||||
4. 保留 `/mindmap/{doc}/{asset}` 作为显式独立页面入口时,应只用于新窗口、新 tab、调试入口或明确的 asset editor,不作为普通文件树主点击默认行为。
|
||||
|
||||
### 阶段二:主编辑区 asset tab
|
||||
|
||||
建议把文件树里的 mindmap asset 打开为主编辑区内的 asset tab / preview,而不是替换 `index.md`:
|
||||
|
||||
- 标签形态类似 `index.md | mindmap.json`。
|
||||
- `index.md` tab 只显示真实页面正文。
|
||||
- `mindmap.json` 或 mindmap asset tab 使用 `{ documentId, mindmapId }` 加载 mindmap projection。
|
||||
- asset tab 保存只写 mindmap command / projection 对应资源,不创建第二份页面正文。
|
||||
- 关闭 asset tab 后回到 `index.md`,正文内容应保持不变。
|
||||
|
||||
### 阶段三:Page Aggregate 与 block-asset 单一真源
|
||||
|
||||
长期应把 mindmap asset 与页面正文 block 关系收口到 Page Aggregate / kernel projection:
|
||||
|
||||
1. 页面正文中的 mindmap block attrs 持有稳定 `mindmapId`。
|
||||
2. 文件树 mindmap asset row 来自同一份 page aggregate/resource projection,而不是另起一套对象真相。
|
||||
3. mindmap asset 的删除、恢复、移动、重命名应同步维护 page body block 与 resource projection 的关系。
|
||||
4. standalone shell 如果继续存在,也必须声明自己是 asset editor,不再构造会被误认为 `index.md` 的页面正文 bootstrap。
|
||||
|
||||
## 7. 建议 smoke 验证
|
||||
|
||||
修复完成后至少补一条文件树 mindmap 回归 smoke:
|
||||
|
||||
1. 登录真实测试账号。
|
||||
2. 新建测试页面,插入或生成一个 mindmap block,记录 `documentId` 与 `mindmapId`。
|
||||
3. 从文件树点击该页面下的 mindmap asset。
|
||||
4. 断言主编辑区没有导航到会污染 `index.md` 的 standalone bootstrap;如果打开 asset tab,则 tab 标识与 `index.md` 分离。
|
||||
5. 在 mindmap 中输入一段较长中文内容并保存。
|
||||
6. 切换到其它页面,再从文件树点击原页面 `index.md`。
|
||||
7. 断言 `index.md` 仍显示真实页面正文,且页面内 mindmap block 仍引用同一个 `mindmapId`。
|
||||
8. 再打开 mindmap asset,断言刚才输入的长中文内容仍存在。
|
||||
|
||||
需要保留一条负向断言:
|
||||
|
||||
- 普通文件树主点击 mindmap asset 不应触发 `window.location.assign('/mindmap/{doc}/{asset}')` 并替换当前文档页正文。
|
||||
|
||||
## 8. 修复证据
|
||||
|
||||
本问题已按 `4-24` 与 `5-12` 收口:
|
||||
|
||||
- `rust/crates/core-protocol/src/kernel.rs` 增加 `KernelObjectIdentity` / `KernelBlockAssetRelation`。
|
||||
- `rust/crates/bridge-runtime/src/lib.rs` 的 file tree projection 为 `index.md`、mindmap、OnlyOffice、代码附件、普通附件输出不同 `resourceMeta.objectIdentity`。
|
||||
- `rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs`、`rust/crates/mnote-web/src/routes/tree.rs`、`rust/crates/mnote-web/src/ssr/pages/layout.rs` 将 `objectIdentity` 下发到文件树 DOM 与 `tree.asset.open` intent。
|
||||
- `/mindmap/{documentId}/{mindmapId}` 由 `rust/crates/mnote-web/src/ssr/pages/mindmap.rs` 明确标记为 `data-mnote-object-editor="mindmap"` 与 `data-mnote-object-identity="resource:mindmap:{documentId}:{mindmapId}"`。
|
||||
- `rust/crates/mnote-web/src/routes/mindmap_shell.rs` 的 standalone bootstrap 使用 `__mindmap_object__:{documentId}:{mindmapId}`,不复用真实页面正文草稿身份。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol --test resource_tree_contract -- --nocapture
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime file_tree_projection -- --nocapture
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web file_tree_projection -- --nocapture
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web mindmap -- --nocapture
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web documents_save_route_executes_page_body_save_command -- --nocapture
|
||||
cd /mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike && cargo test standalone_mindmap_object_uses_isolated_draft_identity -- --nocapture
|
||||
node scripts/task112-tree-rust-family-regression-smoke.js
|
||||
node scripts/task169-mindmap-realtime-smoke.js
|
||||
```
|
||||
|
||||
`task169` 已覆盖:文件树 mindmap asset 打开、长中文编辑、保存、切页、从文件树回 `index.md`、再次打开 mindmap,且未触发 `page.body.save` 污染链。
|
||||
|
||||
## 9. 流转条件
|
||||
|
||||
当前状态:`done`
|
||||
|
||||
只有在以下条件都满足后,本文才能移动到 `bugs/05-editor-mainline/done/`:
|
||||
|
||||
1. [x] 文件树 mindmap asset 普通点击不再污染 `index.md` 主编辑区。
|
||||
2. [x] `index.md` 与页面内 mindmap block 的关系有明确单一真源,至少不再出现切页后只剩默认导图的现象。
|
||||
3. [x] `/mindmap/{doc}/{asset}` 的产品定位被明确为独立页、新 tab、debug 或 asset editor,不再被文件树主路径误用。
|
||||
4. [x] smoke 覆盖长中文 mindmap 编辑、保存、切页、回到 `index.md`、再次打开 asset 的完整链路。
|
||||
5. [x] 浏览器复测确认 mindmap 编辑可用性优先,不因实时刷新或路由切换导致闪烁、跑位或内容错乱。
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
# 5-11 [process][bug] Mindmap 幽灵附件增长与树命令卡顿 v1
|
||||
|
||||
> 更新时间:2026-05-13
|
||||
>
|
||||
> 分类归属:
|
||||
> - `05-editor-mainline/process`
|
||||
> - 涉及边界:`04-tree-domain/tree command + file_tree projection`、`06-mindmap/runtime save + resource relation`
|
||||
>
|
||||
> 用户证据:
|
||||
> - `/mnt/Data1T/mnote/tmp/image copy 94.png`
|
||||
> - `/mnt/Data1T/mnote/tmp/image copy 93.png`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
用户在页面中只是修改了一下 mindmap,文件树/资源树下却陆续出现多个 `mindmap-mindmap...` 附件行。截图显示同一页面 `新页面3` 下有 `index.md`,并且 mindmap 附件从 2 条增长到 4 条。
|
||||
|
||||
同一轮反馈还指出:删除页面和新建页面都很慢,表现为树操作后明显卡顿。
|
||||
|
||||
这不是单纯的图标显示问题。当前症状同时暴露两条链路风险:
|
||||
|
||||
1. mindmap 编辑/初始化/保存链路会多次向资产层广播同一个资源存在,且缺少“页面正文 block 与 mindmap asset 关系唯一”的硬约束。
|
||||
2. tree shell 的页面新建、删除、重命名、移动仍在命令成功后强制整页刷新,和当前 live projection/SSE 机制重复,导致用户感知卡顿。
|
||||
|
||||
## 2. 真实现象
|
||||
|
||||
已观察到的用户现象:
|
||||
|
||||
1. 新页面下初始只有 `index.md` 和少量 mindmap 附件。
|
||||
2. 用户只是编辑 mindmap,不是主动新建 mindmap。
|
||||
3. 等一会儿或再次修改后,同一页面下又出现新的 `mindmap-mindmap...` 行。
|
||||
4. 页面新建和删除动作响应慢,像是页面/树整体重新加载。
|
||||
|
||||
期望结果:
|
||||
|
||||
1. 一个页面内的一个 mindmap block 只对应一个稳定 `mindmapId` 和一个 file tree asset row。
|
||||
2. mindmap 保存只能更新既有资源,不应创建新的 mindmap 资产行。
|
||||
3. file tree projection 应按 `{documentId, blockId, assetId}` 或明确 object identity 去重。
|
||||
4. 页面新建/删除成功后应优先消费 command result / tree delta 更新局部投影,不应默认整页 reload。
|
||||
|
||||
## 3. 初步调查证据
|
||||
|
||||
### 3.1 mindmap 资产广播存在多入口
|
||||
|
||||
`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx` 中同一个 mindmap 资源至少有三处会触发资产刷新广播:
|
||||
|
||||
- 保存成功后广播 `emitAssetsChanged(docId, { id: mindmapId, asset_type: "mindmap", ... })`:`MindmapBlock.tsx:1499` 到 `:1534`
|
||||
- 初始同步 `createOnly: true` 成功后广播:`MindmapBlock.tsx:1591` 到 `:1621`
|
||||
- mindmap 实例就绪后立即广播:`MindmapBlock.tsx:1636` 到 `:1647`
|
||||
|
||||
另一个 legacy/compat block wrapper 也会在 mount 时执行 `createOnly` 并广播资产:`MindmapBlock.tsx:3573` 到 `:3612`。
|
||||
|
||||
这些广播本身用 `id = mindmapId`,理论上同 ID 会被 sidebar 本地 state 去重;但只要保存/转换链路让同一视觉 mindmap 换了新的 `mindmapId`,就会生成新的资产行。
|
||||
|
||||
### 3.2 mindmapId 仍可能由时间戳生成
|
||||
|
||||
当前 `leptos-tiptap` 插入 mindmap 时使用:
|
||||
|
||||
- `rust/spikes/leptos-tiptap-spike/src/lib.rs:5681` 到 `:5689`
|
||||
|
||||
这里 `next_mindmap_id()` 生成 `mindmap_{Date.now()}`,并写入 paragraph attrs 的 `mindmapId`。如果后续转换、保存、重新挂载中丢失原 attrs,fallback 会用新的 block identity / 新插入节点创建新的 `mindmapId`,资产层就会认为这是另一个 mindmap。
|
||||
|
||||
相关转换锚点:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:818` 到 `:838`:legacy block 转 TipTap 时写入 `mindmapId`
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:983` 到 `:1009`:TipTap 节点转 editor block 时若 attrs 缺失则用 blockId fallback
|
||||
- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:188` 到 `:213`:`mindmapReferenceProps` 会在缺少 `mindmapId` 时 fallback 到 blockId
|
||||
- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:410` 到 `:418`:editor block 转 TipTap 时把 mindmap props 写回 paragraph attrs
|
||||
|
||||
当前缺少一条回归断言:连续编辑同一个 mindmap 后,保存前后 `mindmapId` 必须保持不变,且 file tree 下同一页面 mindmap asset 数量不增长。
|
||||
|
||||
### 3.3 Convex mindmaps 表允许同页多 mindmap,但缺少 block 关系唯一约束
|
||||
|
||||
`wolai-frontend/convex/schema.ts:164` 到 `:183` 定义 mindmaps 表,并以 `(document_id, mindmap_id)` 做查询索引。注意这里是普通索引,不是唯一约束。
|
||||
|
||||
`wolai-frontend/convex/mindmaps.ts` 的 `put` 对同一 `(document_id, mindmap_id)` 是幂等 patch/insert,但它并不知道页面正文中的哪个 block 才是唯一来源。也就是说:
|
||||
|
||||
- 同一个 `mindmapId` 重复保存不会多插入。
|
||||
- 如果历史或并发路径已经写出同一 `(document_id, mindmap_id)` 的多行,`put` 当前用 `.first()` 只会 patch 第一行,剩余重复行仍会被后续 list/projection 展示。
|
||||
- 但如果前端生成了新的 `mindmapId`,后端会按合法新 mindmap 插入。
|
||||
- file tree 会把同一 document 下所有 active mindmaps 映射为资产行。
|
||||
|
||||
对应映射:
|
||||
|
||||
- `wolai-frontend/convex/mindmaps.ts:206` 到 `:245`:`put` 查询 `by_doc_mindmap` 后 `.first()`,不存在则 insert
|
||||
- `wolai-frontend/convex/sidebar.ts:79` 到 `:100`:`mindmap_id` 被映射为 `id` 和 `mindmap-{mindmap_id}.json`
|
||||
- `wolai-frontend/convex/sidebar.ts:193` 到 `:220`:所有 active mindmaps 都进入 `mindmap_assets`
|
||||
- `rust/crates/bridge-runtime/src/lib.rs:7569` 到 `:7647`:file tree projection 从 `mindmap_assets` 构建资源行,并只按 asset id 去重
|
||||
|
||||
因此,当前有两个需要实测区分的分支:
|
||||
|
||||
1. 同一视觉对象被保存成多个不同 `mindmapId`,每个都被合法展示为一个资源。
|
||||
2. 数据表中已经存在同一 `(document_id, mindmap_id)` 多行,`.first()` 更新掩盖重复行,sidebar list 把重复行全部暴露出来。
|
||||
|
||||
两者都会在截图中表现为同一页面下多个 `mindmap-mindmap...` 行。
|
||||
|
||||
### 3.4 Rust / Next API 都会把 mindmap 保存转成 tree resync
|
||||
|
||||
保存路径还会触发树刷新:
|
||||
|
||||
- Next API `POST /api/mindmap/[docId]/[mindmapId]` 构造 `mindmaps.put` 或 `mindmap.command.apply`:`wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts:229` 到 `:254`
|
||||
- Rust API 普通保存也包装为 `mindmaps.put` 并携带 `createOnly`:`rust/crates/mnote-web/src/routes/mindmap_api.rs:177` 到 `:200`
|
||||
- Bridge runtime 给 `mindmaps.put` 生成 `resync_required` 树事件:`rust/crates/bridge-runtime/src/lib.rs:9273` 到 `:9326`
|
||||
|
||||
这意味着 mindmap 每次保存都会推动资源树重新读 projection;如果底层 mindmaps 数据已经重复,保存/刷新会把重复行显性化。
|
||||
|
||||
## 4. 页面新建/删除慢的证据
|
||||
|
||||
tree shell 在多处命令成功后调用 `scheduleRefresh()`,而 `scheduleRefresh()` 的实现是 80ms 后整页 reload 或带 `renameRowId` 重新 assign:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/tree.rs:2976` 到 `:2986`
|
||||
|
||||
调用点包括:
|
||||
|
||||
- 文件树删除:`rust/crates/mnote-web/src/routes/tree.rs:2791` 到 `:2824`
|
||||
- 新建页面:`rust/crates/mnote-web/src/routes/tree.rs:4309` 到 `:4340`
|
||||
- 重命名:`rust/crates/mnote-web/src/routes/tree.rs:4373` 到 `:4406`
|
||||
- 移动:`rust/crates/mnote-web/src/routes/tree.rs:4448` 到 `:4502`
|
||||
|
||||
与此同时,主页面已经有 tree live controller 接收 `snapshot` / `delta` / `resync`:
|
||||
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3881` 到 `:3917`
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3939` 到 `:4050`
|
||||
|
||||
这会造成两种低效叠加:
|
||||
|
||||
1. 命令结果本身已经返回 `tree.node.created` / `tree.node.archived` 等 delta hint。
|
||||
2. 前端仍然整页刷新,重新加载 sidebar、file tree、workspace shell、编辑器 runtime。
|
||||
|
||||
这解释了“新建页面和删除页面都很慢”的用户感知。
|
||||
|
||||
## 5. 当前判断
|
||||
|
||||
当前根因尚需实测最终确认,但静态代码已经能支持以下判断:
|
||||
|
||||
1. mindmap ghost asset 增长的高概率根因是 `mindmapId` 稳定性没有被端到端锁死。只要编辑/保存/重挂载过程中 attrs 丢失或重新创建 block,就会插入新 `mindmap_{timestamp}`,后端会合法保存,file tree 会合法展示。
|
||||
2. 另一个可疑根因是 `mindmaps` 表没有唯一约束,`put` 只 patch `.first()`,无法清除或阻止同键重复行。
|
||||
3. 资产广播入口过多会放大问题。它们让新 mindmapId 或重复数据几乎立即进入 sidebar 本地 state 和 Convex projection,用户就会看到“自己又新增了一个”。
|
||||
4. file tree projection 只按 `asset.id` 去重,无法识别“同一 document + 同一正文 block 的多个 mindmapId 其实是幽灵副本”,也无法处理同 id 多行投影的上游异常。
|
||||
5. 页面新建/删除慢不是 Convex 单点问题,当前 tree shell 命令成功后仍强制 reload,是明确的性能和体验缺陷。该慢操作应单独归入 `04-tree-domain` 跟踪,本文只保留关联证据。
|
||||
|
||||
## 6. 建议复现与验证
|
||||
|
||||
建议补一条失败优先 smoke,先不要直接修代码:
|
||||
|
||||
1. 使用真实测试账号登录 `http://localhost:3000/auth`。
|
||||
2. 新建测试页面,记录 `documentId`。
|
||||
3. 插入一个 mindmap,记录首个 `mindmapId`。
|
||||
4. 连续修改 mindmap 中心主题或新增节点 3 次,每次等待保存完成。
|
||||
5. 切到文件树,统计该页面下 `asset_type=mindmap` 的行数和 `data-mnote-object-identity`。
|
||||
6. 刷新页面后再次统计。
|
||||
7. 断言同一页面下 mindmap asset 数量仍为 1,且 `mindmapId` 未变化。
|
||||
8. 同一脚本计时新建页面、删除页面从点击到 DOM 稳定的耗时,并记录是否触发 `window.location.reload()` / navigation。
|
||||
|
||||
建议同时检查 Convex 数据:
|
||||
|
||||
- `mindmaps` 中同一 `document_id` 下是否出现多个 `mindmap_id`。
|
||||
- 新增出来的 `mindmap_id` 是否都形如 `mindmap_<timestamp>`。
|
||||
- 页面正文 TipTap JSON 中是否只有一个 mindmap paragraph,且 attrs.mindmapId 是否随保存变化。
|
||||
|
||||
## 7. 建议修复方向
|
||||
|
||||
### 阶段一:防止继续增长
|
||||
|
||||
1. mindmap block 创建时生成一次稳定 `blockId` 与 `mindmapId`,后续保存、转换、重挂载必须保留。
|
||||
2. `documents.save` / TipTap converter 对 `mnoteBlockType=mindmap` 增加 contract:缺少 `mindmapId` 时不得静默新建时间戳 ID,应先从 block identity / object identity 恢复,恢复不了则报可观测错误。
|
||||
3. `mindmaps.put` 或上层 command 增加可选 `blockId` / `blockAssetRelation`,对同一 `{documentId, blockId}` 已有关联 mindmap 时拒绝插入第二个 mindmapId。
|
||||
4. asset 广播入口收口:保存成功、initial createOnly、实例就绪不应都伪造 asset;前端应优先消费 kernel/file tree projection 返回的 object identity。
|
||||
|
||||
### 阶段二:清理幽灵副本
|
||||
|
||||
1. 提供只读诊断脚本:列出同一页面下多个 mindmapId、对应 updated_at、正文引用的 mindmapId。
|
||||
2. 只有在用户确认后,才可清理未被页面正文引用的 mindmap 行。
|
||||
3. 清理必须进入 bug 修复 checklist,不能在本缺陷说明阶段直接删除数据。
|
||||
|
||||
### 阶段三:树命令性能收口
|
||||
|
||||
1. `tree.node.create` 成功后用 command result / delta 局部插入节点,而不是 reload。
|
||||
2. `tree.node.archive` 成功后用 `remove_document` delta 局部移除节点。
|
||||
3. 只有 projection 丢失、SSE 断线或 reducer 无法应用时才走 resync/reload fallback。
|
||||
4. smoke 增加“无整页 reload”断言和耗时阈值。
|
||||
|
||||
## 8. 流转条件
|
||||
|
||||
当前状态:`process`
|
||||
|
||||
只有满足以下条件后才能移动到 `bugs/05-editor-mainline/done/`:
|
||||
|
||||
1. [ ] 已有 smoke 能稳定复现当前 mindmap 资产增长问题,或能证明 Convex 数据中存在同页多 mindmap 幽灵副本。
|
||||
2. [ ] 同一 mindmap 连续编辑保存后,`mindmapId` 和 file tree object identity 保持稳定。
|
||||
3. [ ] 同一页面下未主动新建多个 mindmap 时,file tree 只出现一个 mindmap asset row。
|
||||
4. [ ] 新建页面和删除页面不再默认整页 reload,或至少有明确 fallback 条件。
|
||||
5. [ ] 浏览器实测记录新建/删除耗时,并明显低于当前整页 reload 体验。
|
||||
6. [ ] 若清理历史幽灵副本,必须经用户确认,并保留清理前后证据。
|
||||
@@ -0,0 +1,114 @@
|
||||
# 5-9-C12 [process][bug] 文档页顶栏左上角切换侧栏按钮无效 v1
|
||||
|
||||
> 更新时间:2026-05-12
|
||||
>
|
||||
> 分类归属:
|
||||
> - `05-editor-mainline/process`
|
||||
> - 对应体验主线:`5-9 Wolai-aline continuous checklist`
|
||||
>
|
||||
> 关联文档:
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
当前 `3000` 文档页顶部左上角的 `切换侧栏` 按钮可以被点击,但点击后左侧 sidebar 没有收起,再次点击也没有重新展开的状态变化。
|
||||
|
||||
这不是页面树节点的展开/折叠问题,而是文档页壳层的 `sidebar shell toggle` 缺失。
|
||||
|
||||
## 2. 归类理由
|
||||
|
||||
本问题归到 `05-editor-mainline`,而不是 `04-tree-domain`,理由如下:
|
||||
|
||||
- 问题入口位于文档页顶栏,不在 `page_tree/file_tree` 行内交互层。
|
||||
- 问题影响的是页面壳、顶栏和侧栏显隐体验,属于 `Wolai 页面树与主编辑器体验复刻` 主线。
|
||||
- `04-tree-domain` 更关注树 projection、row model、selection/focus、keyboard、DnD、tree command 等合同;本文问题不在这些运行时合同里。
|
||||
|
||||
## 3. 真实现象
|
||||
|
||||
复现路径:
|
||||
|
||||
1. 打开 `http://127.0.0.1:3000/auth`
|
||||
2. 点击 `测试账号快速登录`
|
||||
3. 进入文档页后,点击顶栏左上角 `切换侧栏`
|
||||
4. 观察左侧 sidebar 是否收起
|
||||
5. 再点击一次,观察是否重新展开
|
||||
|
||||
实际结果:
|
||||
|
||||
- 第 1 次点击后,左侧 sidebar 仍然完整可见
|
||||
- 第 2 次点击后,左侧 sidebar 仍然完整可见
|
||||
- 页面 URL 不变
|
||||
- 浏览器 console 未出现报错
|
||||
- 关键 network 未出现与 toggle 对应的新请求
|
||||
|
||||
期望结果:
|
||||
|
||||
- 第 1 次点击应收起左侧 sidebar
|
||||
- 第 2 次点击应恢复展开
|
||||
- 行为应与 Wolai / 常规文档页壳的 sidebar toggle 一致
|
||||
|
||||
## 4. 证据
|
||||
|
||||
截图证据:
|
||||
|
||||
- 切换前:`/mnt/Data1T/mnote/tmp/hermes-tester/manual-before-toggle.png`
|
||||
- 点击一次后:`/mnt/Data1T/mnote/tmp/hermes-tester/manual-after-toggle.png`
|
||||
- 点击两次后:`/mnt/Data1T/mnote/tmp/hermes-tester/manual-after-second-toggle.png`
|
||||
|
||||
Hermes tester 首轮取证:
|
||||
|
||||
- `/mnt/Data1T/mnote/tmp/hermes-tester/first-run/screenshots/01-auth-entry.png`
|
||||
- `/mnt/Data1T/mnote/tmp/hermes-tester/first-run/screenshots/02-after-quick-login.png`
|
||||
|
||||
窄范围侧栏取证:
|
||||
|
||||
- `/mnt/Data1T/mnote/tmp/hermes-tester/sidebar-run/01-auth.png`
|
||||
- `/mnt/Data1T/mnote/tmp/hermes-tester/sidebar-run/02-after-login-before-toggle.png`
|
||||
|
||||
代码锚点:
|
||||
|
||||
- 顶栏按钮渲染位于 `/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:3938`
|
||||
- 当前仅能找到按钮渲染与样式,未看到对应的显隐切换绑定:
|
||||
- `/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:3938`
|
||||
- `/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/styles.rs:1940`
|
||||
|
||||
## 5. 当前判断
|
||||
|
||||
当前更像是:
|
||||
|
||||
- 顶栏按钮已经进入 SSR 页面壳
|
||||
- 但 `sidebar open/collapsed` 状态未接线
|
||||
- 或按钮没有绑定到实际的 sidebar toggle handler
|
||||
|
||||
也就是说,这是一条“控件已渲染,但未驱动真实状态”的缺陷,而不是视觉误差。
|
||||
|
||||
## 6. 建议完成方式
|
||||
|
||||
完成本缺陷至少需要满足下面四条:
|
||||
|
||||
1. 顶栏 `切换侧栏` 按钮绑定真实的 sidebar toggle 状态
|
||||
2. 第 1 次点击后 sidebar 收起
|
||||
3. 第 2 次点击后 sidebar 再展开
|
||||
4. 增补可回归 smoke,覆盖:
|
||||
- 登录后点击 toggle
|
||||
- 收起态存在明确 DOM/样式状态
|
||||
- 再次点击后恢复展开
|
||||
|
||||
建议验收同时包含:
|
||||
|
||||
- 代码验证:相关 Rust SSR / 前端状态接线
|
||||
- 浏览器 smoke:真实点击与状态断言
|
||||
- 截图复核:收起前 / 收起后 / 再展开后三张图
|
||||
|
||||
## 7. 流转条件
|
||||
|
||||
当前状态:`process`
|
||||
|
||||
只有在以下条件都满足后,本文才能移动到 `bugs/05-editor-mainline/done/`:
|
||||
|
||||
1. 真实代码已修复
|
||||
2. smoke 已覆盖且通过
|
||||
3. 浏览器复测确认收起/展开都生效
|
||||
4. 证据路径已补齐到修复后的截图或日志
|
||||
@@ -0,0 +1,34 @@
|
||||
# bugs 缺陷索引
|
||||
|
||||
> 更新时间:2026-05-12
|
||||
>
|
||||
> 状态口径以当前仓库真实代码与真实验证结果为准:
|
||||
> - `[done]`:问题已修复,且已通过代码、smoke、浏览器或截图证据验证
|
||||
> - `[process]`:问题已确认存在,仍在修复、验证或等待收口
|
||||
|
||||
## 分类方式
|
||||
|
||||
- `bugs/` 默认镜像 `design/` 的主线分类方式。
|
||||
- 当前主线缺陷按对应大类放置,例如:
|
||||
- `01-tree-first-graph-kernel/`
|
||||
- `02-convex-rust-long-term-architecture/`
|
||||
- `03-rust-web/`
|
||||
- `04-tree-domain/`
|
||||
- `05-editor-mainline/`
|
||||
- `06-mindmap/`
|
||||
- `07-ai/`
|
||||
- 每个大类继续按 `process/` 与 `done/` 分层。
|
||||
|
||||
## 归类原则
|
||||
|
||||
- 缺陷归类以真正 owner 和长期主线为准,不以表面症状命名。
|
||||
- `sidebar`、`topbar`、`breadcrumb`、文档页壳、Wolai 体验对齐、页面树与主编辑器整体体验问题,优先归到 `05-editor-mainline/`。
|
||||
- `projection`、`row model`、`selection/focus model`、`keyboard`、`DnD`、`tree command`、`page_tree/file_tree/sidebar_tree` 协议问题,优先归到 `04-tree-domain/`。
|
||||
- `transport`、route、SSR page shell owner、compat/bridge 边界问题,按 owner 判断是否落到 `03-rust-web/` 或 `05-editor-mainline/`。
|
||||
|
||||
## 流转规则
|
||||
|
||||
- 新确认的缺陷先进入对应大类的 `process/`。
|
||||
- 缺陷在真实代码中修复并完成对应验证后,必须移动到对应大类的 `done/`。
|
||||
- 不允许在 `process/` 与 `done/` 同时保留同一条缺陷。
|
||||
- 若后续发现旧缺陷记录归类错误,应直接迁移到正确大类,而不是在错误大类继续追加。
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# 4-24 [done] Resource Tree / File Tree / Page Tree 真源合同与执行清单 v1
|
||||
|
||||
> 更新时间:2026-05-13
|
||||
>
|
||||
> 上游依据:
|
||||
> - `/mnt/Data1T/mnote/design/10-review/05-tree.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/10-review/README.md`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本清单用于把 `design/10-review/05-tree.md` 中的判断落到树域主线:
|
||||
|
||||
- `Resource Tree` 是长期对象组织真源,由 Rust kernel 持有语义。
|
||||
- `File Tree` 是 Resource Tree 的主组织投影,展示页面、`index.md`、附件、mindmap、OnlyOffice、代码附件等资源。
|
||||
- `Page Tree` 是页面导航投影 / 快捷视图,不拥有排序、父子、附件归属或资源归属的最终真相。
|
||||
- Sidebar、文件树 UI、页面树 UI 都只能消费 projection 和发 command,不能重新拼第二套结构真相。
|
||||
|
||||
## 2. 术语冻结
|
||||
|
||||
- `Resource Tree`:workspace 内对象与资源的 canonical hierarchy。它不是 UI 文件树组件,而是 kernel 层的资源组织语义。
|
||||
- `File Tree`:Resource Tree 面向 VS Code Explorer 体验的 projection。
|
||||
- `Page Tree`:Resource Tree 中页面对象的导航 projection,可做快捷显示,但不拥有资源归属。
|
||||
- `ObjectIdentity`:打开、保存、草稿、tab、command 使用的对象身份。
|
||||
- `BlockAssetRelation`:页面正文 block 与资源对象之间的稳定关联,例如 `documentId + blockId + assetId + assetKind`。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不在本阶段重写 Convex 底层存储。
|
||||
- 不在本阶段一次性删除所有 compat/fallback。
|
||||
- 不把文件树 UI 组件提升为真源。
|
||||
- 不让 Page Tree 接管附件、mindmap 或 OnlyOffice 的归属。
|
||||
|
||||
## 4. 前置顺序与执行门
|
||||
|
||||
本清单是 `5-12` 的前置合同。执行顺序固定为:
|
||||
|
||||
1. 先完成本文件的 `Resource Tree / File Tree / Page Tree` 真源合同。
|
||||
2. 再执行 `design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md` 中的主编辑区 object tab、草稿隔离和打开路径调整。
|
||||
|
||||
不允许绕过本文件直接在编辑区里新增第二套 asset / page 归属判断。编辑区可以先做 mindmap 止血,但止血代码必须只消费本文件定义的 object identity / file tree intent,不得把临时前端判断提升为长期合同。
|
||||
|
||||
当前代码基线:
|
||||
|
||||
- `rust/crates/core-protocol/src/kernel.rs` 已有 `KernelProjectionResourceKind`、`KernelProjectionAssetKind`、`KernelProjectionResourceMeta`、`KernelProjectionItem`。
|
||||
- `rust/crates/bridge-runtime/src/lib.rs` 已有 `build_file_tree_projection_result`、`make_projection_resource_meta`、`normalize_file_tree_asset` 等 file tree projection 构造逻辑。
|
||||
- `rust/crates/mnote-web/src/routes/tree.rs` 与 `rust/crates/mnote-web/src/ssr/pages/layout.rs` 已消费 `resourceMeta` 并发出 `tree.asset.open`。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 当前仍把 mindmap asset 主路径导航到 `/mindmap/{documentId}/{assetId}`,这是 `5-10` bug 的直接落点,但长期合同归属仍应先在本文件冻结。
|
||||
|
||||
阶段门:
|
||||
|
||||
- G1:协议能表达 `index.md`、mindmap、OnlyOffice、普通附件、代码附件的不同 object identity。
|
||||
- G2:`file_tree` projection row 能稳定输出 object identity 或等价 `resourceMeta.extra.objectIdentity`。
|
||||
- G3:`page_tree` projection 被约束为页面导航投影,不承载 asset 归属。
|
||||
- G4:`tree.asset.open` 只表达打开资源对象的 intent,不决定“把资源当正文打开”。
|
||||
- G5:完成 G1-G4 后,`5-12` 才能把主编辑区打开路径、草稿 key、保存命令接入 object tab / object editor 规则。
|
||||
|
||||
## 5. 执行清单
|
||||
|
||||
### 5.1 协议模型
|
||||
|
||||
- [x] 在 `rust/crates/core-protocol/` 增加或扩展资源对象协议。
|
||||
- 优先落点:`rust/crates/core-protocol/src/kernel.rs`,必要时拆出 `rust/crates/core-protocol/src/resource.rs` 并在 `lib.rs` re-export。
|
||||
- 当前已有 `KernelProjectionResourceKind`,本阶段应补齐或映射为长期 `ResourceKind` 语义。
|
||||
- 最小资源种类:`page`、`index`、`mindmap`、`attachment`、`onlyoffice`、`code`。
|
||||
- `ObjectIdentity` 最小字段:`objectKind`、`documentId`、`blockId`、`assetId`。
|
||||
- `BlockAssetRelation` 最小字段:`documentId`、`blockId`、`assetId`、`assetKind`。
|
||||
- 命名规则:Rust 类型使用 `KernelObjectIdentity` / `KernelBlockAssetRelation` 或等价项目内前缀;序列化字段使用 camelCase。
|
||||
|
||||
- [x] 补协议单测。
|
||||
- 文件建议:`rust/crates/core-protocol/tests/resource_tree_contract.rs`。
|
||||
- 断言:mindmap、OnlyOffice、普通附件都能表达为 resource object。
|
||||
- 断言:`index.md` 的 object identity 与 mindmap asset 的 object identity 不相等。
|
||||
- 断言:`BlockAssetRelation` 能表达 `documentId + blockId + assetId + assetKind`,且不会被反序列化为页面正文 identity。
|
||||
- 运行命令:
|
||||
- `cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol --test resource_tree_contract -- --nocapture`
|
||||
- 预期:新增协议测试通过,失败时不得继续进入 `5-12` 的 P1/P2。
|
||||
|
||||
### 5.2 Projection 合同
|
||||
|
||||
- [x] 扩展 `file_tree` projection 的 row contract。
|
||||
- 每个 row 必须带稳定 `row_id`、`node_id`、`projection_kind`、`resource_meta`。
|
||||
- `resource_meta` 至少包含 `resourceKind`、`documentId`、`assetId`、`assetKind`、`objectIdentity`。
|
||||
- 优先修改点:`rust/crates/core-protocol/src/kernel.rs` 的 `KernelProjectionResourceMeta`,以及 `rust/crates/bridge-runtime/src/lib.rs` 的 `make_projection_resource_meta`。
|
||||
- 兼容策略:已有 `resourceKind/documentId/assetId/assetKind` 保持不破坏;新增字段可先放入 `resourceMeta.extra.objectIdentity`,待前后端消费稳定后再提升为强类型字段。
|
||||
|
||||
- [x] 固定 `page_tree` projection 的降级边界。
|
||||
- `page_tree` 只输出页面导航关系。
|
||||
- `page_tree` 不输出附件归属、mindmap 归属、OnlyOffice 归属的最终判断。
|
||||
- 如需显示资源状态,只能引用 `resource_meta` 或 resource projection,不得在 page tree renderer 内拼装。
|
||||
- 优先检查点:`rust/crates/bridge-runtime/src/lib.rs` 中 `page_tree` projection 构造逻辑、`wolai-frontend/src/lib/tree-projection.ts`、`wolai-frontend/src/lib/documents/page-subtree.ts`。
|
||||
|
||||
- [x] 补 Rust projection 测试。
|
||||
- 文件优先看:`rust/crates/bridge-runtime/src/lib.rs`、`rust/crates/mnote-web/src/tree_shell/`。
|
||||
- 测试要求:同一页面下的 `index.md` 与 mindmap asset 生成不同 row,但共享同一 `documentId`,并通过 `BlockAssetRelation` 建立关系。
|
||||
- 建议新增或扩展测试:
|
||||
- `kernel_project_view_query_executes_into_file_tree_projection_contract`
|
||||
- `kernel_file_tree_projection_query_supports_extended_resources_and_max_results`
|
||||
- 新增 `kernel_file_tree_projection_separates_index_and_mindmap_object_identity`
|
||||
- 运行命令:
|
||||
- `cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime file_tree_projection -- --nocapture`
|
||||
- `cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web file_tree_projection -- --nocapture`
|
||||
|
||||
### 5.3 File Tree 派生规则
|
||||
|
||||
- [x] 页面节点下固定派生 `index.md` row。
|
||||
- `index.md` row 只能打开 Page Aggregate body。
|
||||
- `index.md` row 不得根据页面内第一个 block 类型改变打开目标。
|
||||
- 优先检查点:`rust/crates/bridge-runtime/src/lib.rs` 的 `build_file_tree_projection_result`,当前已固定创建 `index:{documentId}` node。
|
||||
- 前端消费检查点:`rust/crates/mnote-web/src/ssr/pages/layout.rs` 的 file tree row click 分支。
|
||||
|
||||
- [x] 页面内资源固定派生为 child resource row。
|
||||
- mindmap row 打开 mindmap object editor。
|
||||
- OnlyOffice row 打开 OnlyOffice object editor。
|
||||
- 代码附件 row 打开代码/预览编辑器。
|
||||
- 普通附件 row 打开签名资源或预览。
|
||||
- 优先检查点:`rust/crates/bridge-runtime/src/lib.rs` 的 `normalize_file_tree_asset`、`infer_file_tree_asset_shape`、`classify_asset_resource_kind`。
|
||||
- 必须保留 `documentId` 与 `assetId`,供 `5-12` 的 object tab identity 使用。
|
||||
|
||||
- [x] 文件树 row 不得从前端多份数据临时拼真相。
|
||||
- 允许前端显示、筛选、选中、展开。
|
||||
- 不允许前端决定资源归属、排序真相或 block-asset relation 真相。
|
||||
- 前端允许做的本地状态:`expanded`、`selected`、`hover`、`focus`、`dragging`、`drop target`。
|
||||
- 前端禁止新增长期字段:`assetParentDocumentId`、`mindmapOwnerPageId`、`pageTreeAssetChildren` 这类与 kernel projection 重复的归属真相。
|
||||
|
||||
### 5.4 Page Tree 派生规则
|
||||
|
||||
- [x] Page Tree 只消费页面导航投影。
|
||||
- 可显示页面标题、层级、当前页、快捷入口。
|
||||
- 不负责附件、mindmap、OnlyOffice、代码附件的生命周期。
|
||||
|
||||
- [x] Page Tree command 只发页面导航相关 command。
|
||||
- 页面新建、重命名、移动、归档继续走 `tree.node.*` 或正式 page/tree command。
|
||||
- 资源 attach/detach/rename/move 不进入 page tree 私有命令。
|
||||
- 负向检查:Page Tree renderer / host 中不得新增 mindmap、OnlyOffice、附件归属推导分支。
|
||||
|
||||
### 5.5 Command 边界
|
||||
|
||||
- [x] 新增或确认资源关系 command family。
|
||||
- `tree.asset.attach`
|
||||
- `tree.asset.detach`
|
||||
- `tree.resource.rename`
|
||||
- `tree.resource.move`
|
||||
|
||||
- [x] 明确不同对象写入 owner。
|
||||
- 页面正文:`page.body.save`
|
||||
- 页面标题:`page.head.updateTitle`
|
||||
- 页面设置:`page.layout.updateOptions`
|
||||
- mindmap 内容:`mindmap.command.apply`
|
||||
- OnlyOffice 内容:OnlyOffice callback / forcesave 写回链
|
||||
- 资源归属:`tree.asset.*` / `tree.resource.*`
|
||||
- 优先检查点:
|
||||
- `rust/crates/bridge-runtime/src/lib.rs` command plan
|
||||
- `rust/crates/mnote-web/src/transport/convex.rs` command adapter
|
||||
- `wolai-frontend/src/lib/documents/rust-runtime.test.ts`
|
||||
|
||||
### 5.6 Smoke 与验收
|
||||
|
||||
- [x] 增加 Resource Tree / File Tree projection smoke。
|
||||
- 断言文件树同页下可见 `index.md`、mindmap、附件。
|
||||
- 断言点击 `index.md` 只打开 Page Aggregate body。
|
||||
- 断言点击 mindmap 不进入 Page Aggregate body 保存链。
|
||||
- 可复用脚本:`scripts/task112-tree-rust-family-regression-smoke.js`。
|
||||
- 建议新增断言脚本:`scripts/task170-resource-tree-filetree-source-smoke.js`,或在 task112 中增加独立 case。
|
||||
- 运行命令:
|
||||
- `node scripts/task112-tree-rust-family-regression-smoke.js`
|
||||
- 若新增脚本:`node scripts/task170-resource-tree-filetree-source-smoke.js`
|
||||
|
||||
- [x] 增加 Page Tree 负向 smoke。
|
||||
- 断言 Page Tree 不显示资源归属为自己的结构真相。
|
||||
- 断言 Page Tree 操作不会改变 mindmap/附件归属。
|
||||
- 可复用脚本:`scripts/task112-tree-rust-family-regression-smoke.js`。
|
||||
- 负向检查输出需记录:操作 Page Tree 后,同一 `assetId` 的 `documentId` / `objectIdentity` 未变化。
|
||||
|
||||
## 6. 建议提交切片
|
||||
|
||||
本文件只定义设计顺序,不要求一次提交完成全部实现。后续执行时建议按以下切片推进:
|
||||
|
||||
1. 协议切片:`core-protocol` 增补 object identity / relation 类型与测试。
|
||||
2. Projection 切片:`bridge-runtime` 与 `mnote-web` file tree projection 输出 object identity。
|
||||
3. UI 消费切片:file tree host 只转发 object open intent,不拼归属真相。
|
||||
4. Page Tree 负向切片:补 page tree 不持有资源归属的单测或 smoke。
|
||||
5. 验收切片:跑 Rust 测试与真实浏览器 smoke,把证据补回对应 bug / design 文档。
|
||||
|
||||
## 7. 验收命令
|
||||
|
||||
最低验收命令:
|
||||
|
||||
```bash
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol --test resource_tree_contract -- --nocapture
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime file_tree_projection -- --nocapture
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web file_tree_projection -- --nocapture
|
||||
node scripts/task112-tree-rust-family-regression-smoke.js
|
||||
```
|
||||
|
||||
如执行环境没有启动 `3000`,先运行仓库常用入口:
|
||||
|
||||
```bash
|
||||
npm run desktop:hot
|
||||
```
|
||||
|
||||
真实浏览器 smoke 必须使用默认测试账号 `mnote.e2e@example.com` / `MnoteE2E123!`,不能用 `devFallback` 代替登录态。
|
||||
|
||||
## 8. Done Gate
|
||||
|
||||
本文移动到 `done/` 前必须满足:
|
||||
|
||||
- [x] `Resource Tree`、`File Tree`、`Page Tree` 三个术语在协议和文档中不再混用。
|
||||
- [x] `file_tree` row 能表达 `index.md`、mindmap、OnlyOffice、附件、代码附件的不同 object identity。
|
||||
- [x] `page_tree` 被验证为页面导航 projection,不拥有资源归属真相。
|
||||
- [x] mindmap 与 `index.md` 的打开、保存、草稿身份互相隔离。
|
||||
- [x] 至少一条真实浏览器 smoke 覆盖 `index.md + mindmap asset + 切页 + 再打开` 的完整链路。
|
||||
|
||||
## 9. 完成证据
|
||||
|
||||
- 协议:`rust/crates/core-protocol/src/kernel.rs` 增加 `KernelObjectIdentity`、`KernelObjectKind`、`KernelBlockAssetRelation`,并由 `rust/crates/core-protocol/tests/resource_tree_contract.rs` 覆盖。
|
||||
- Projection:`rust/crates/bridge-runtime/src/lib.rs` 的 file tree projection 输出 `resourceMeta.objectIdentity` / `blockAssetRelation`,并覆盖 index、mindmap、OnlyOffice、代码附件、普通附件。
|
||||
- UI intent:`rust/crates/mnote-web/src/ssr/pages/layout.rs`、`rust/crates/mnote-web/src/routes/tree.rs`、`rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs` 将 `objectIdentity` 传入 file tree DOM 与 `tree.asset.open`。
|
||||
- Smoke:`node scripts/task112-tree-rust-family-regression-smoke.js` 通过,page tree host / file tree host 主链为 `dom`;debug tree shell 关闭项按当前主线记录为 skipped。
|
||||
- Mindmap 链路:`node scripts/task169-mindmap-realtime-smoke.js` 通过,覆盖 mindmap asset 打开、长中文编辑、保存、切页、回 `index.md`、再打开 mindmap。
|
||||
|
||||
## 10. 交接给 5-12 的输入
|
||||
|
||||
完成本文件后,`5-12` 可以依赖以下输入推进主编辑区:
|
||||
|
||||
- 文件树 `index.md` row 的 `objectIdentity`:`page:index:{documentId}`。
|
||||
- 文件树 mindmap row 的 `objectIdentity`:`resource:mindmap:{documentId}:{assetId}`。
|
||||
- 文件树 OnlyOffice row 的 `objectIdentity`:`resource:onlyoffice:{documentId}:{assetId}`。
|
||||
- 文件树普通附件 row 的 `objectIdentity`:`resource:attachment:{documentId}:{assetId}`。
|
||||
- 文件树代码附件 row 的 `objectIdentity`:`resource:code:{documentId}:{assetId}`。
|
||||
- Page Tree 不再作为资源归属输入;它只提供页面导航选择。
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
# 5-12 [done] 主编辑区 Object Tab 与 Resource Tree 对齐执行清单 v1
|
||||
|
||||
> 更新时间:2026-05-13
|
||||
>
|
||||
> 上游依据:
|
||||
> - `/mnt/Data1T/mnote/design/10-review/05-tree.md`
|
||||
> - `/mnt/Data1T/mnote/bugs/05-editor-mainline/done/5-10-mindmap-filetree-index-single-truth-split-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本清单用于把主编辑区从“只等于页面正文编辑器”推进为 workspace object tab host:
|
||||
|
||||
- `index.md` tab 只编辑 Page Aggregate body。
|
||||
- mindmap tab 只编辑 mindmap projection / command。
|
||||
- OnlyOffice tab 只编辑对应附件对象。
|
||||
- 代码附件 / 普通附件 tab 只编辑或预览对应资源对象。
|
||||
- 所有 tab 使用独立 `ObjectIdentity`、草稿身份和保存命令,不互相伪装。
|
||||
|
||||
## 2. 当前优先级
|
||||
|
||||
P0 是关闭当前 mindmap 文件树打开污染 `index.md` 的缺陷。
|
||||
|
||||
P1 是建立主编辑区 object tab 的最小协议,让后续 mindmap、OnlyOffice、附件都能进入同一套打开/关闭/保存/恢复模型。
|
||||
|
||||
P2 是把 Page Aggregate 的 block-asset relation 与 Resource Tree projection 串起来。
|
||||
|
||||
执行前置:
|
||||
|
||||
- 必须先按 `design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` 固定 Resource Tree / File Tree / Page Tree 的真源合同。
|
||||
- 本文件不定义新的资源归属真相,只消费 `4-24` 输出的 `ObjectIdentity`、`resourceMeta` 和 `tree.asset.open` intent。
|
||||
- P0 可以作为止血先落地,但 P0 中所有草稿 key、保存命令、打开路径都必须与 `4-24` 的 object identity 兼容。
|
||||
|
||||
当前关键代码锚点:
|
||||
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:file tree asset 打开事件、`buildMindmapOpenPath`、`openConvexAssetFromFileTree`、`tree.asset.open` listener。
|
||||
- `rust/crates/mnote-web/src/routes/mindmap_shell.rs`:standalone mindmap shell 与临时 bootstrap。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/mindmap.rs`:mindmap object editor 页面标记与 command form。
|
||||
- `rust/crates/mnote-web/src/routes/mindmap_api.rs`:mindmap command / put 保存链。
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs`:`page.body.save` 页面正文保存链。
|
||||
- `rust/spikes/leptos-tiptap-spike/src/lib.rs`:文档页 island 草稿、bootstrap、保存 runtime。
|
||||
- `scripts/task169-mindmap-realtime-smoke.js`:当前 mindmap 长中文、保存、实时刷新 smoke,应升级为本 bug 的主验收脚本。
|
||||
|
||||
## 3. P0:mindmap object editor 止血清单
|
||||
|
||||
### 3.1 打开路径
|
||||
|
||||
- [x] 文件树 mindmap asset 普通点击必须打开明确的 mindmap object editor。
|
||||
- 当前允许继续使用 `/mindmap/{documentId}/{mindmapId}`。
|
||||
- 该路由必须声明自己是 object editor / asset editor。
|
||||
- 不允许被 `index.md` 吞掉。
|
||||
- 不允许把临时 mindmap-only bootstrap 视为真实页面正文。
|
||||
- 推荐实现口径:
|
||||
- 保留 `/mindmap/{documentId}/{mindmapId}` 作为独立 object editor 路由。
|
||||
- 在 HTML 根节点或主容器加可测标记,例如 `data-mnote-object-editor="mindmap"`、`data-mnote-object-identity="resource:mindmap:{documentId}:{mindmapId}"`。
|
||||
- file tree 点击 mindmap 后,若继续整页导航,必须让页面显式进入 mindmap object editor,而不是复用文档页正文 island 的页面草稿 key。
|
||||
- 后续 object tab host 接入后,再把整页导航替换为主编辑区 tab 打开。
|
||||
|
||||
- [x] `index.md` 点击必须只打开真实文档页。
|
||||
- 目标为 `/documents/{documentId}?treeView=filetree`。
|
||||
- 读取来源为 Rust `/api/page-aggregate/{documentId}`。
|
||||
- 不读取 mindmap standalone 草稿。
|
||||
- 推荐检查点:
|
||||
- `layout.rs` 中 file tree row click 对 `index/document/markdown` 的分支。
|
||||
- `leptos-tiptap` island 初始化时是否优先使用服务端 Page Aggregate bootstrap。
|
||||
|
||||
### 3.2 草稿与保存身份
|
||||
|
||||
- [x] standalone mindmap 的草稿 key 必须使用 object identity。
|
||||
- 推荐格式:`__mindmap_object__:{documentId}:{mindmapId}`。
|
||||
- 禁止使用真实页面的 `workspaceId:documentId` 草稿 key。
|
||||
- 推荐检查点:`rust/spikes/leptos-tiptap-spike/src/lib.rs` 内 localStorage 草稿读写 key 生成逻辑。
|
||||
- 负向要求:打开 `/mindmap/{documentId}/{mindmapId}` 后,localStorage 中不得新增或覆盖文档正文草稿 key。
|
||||
|
||||
- [x] 文档页有服务端 bootstrap content 时,不得被 localStorage 旧草稿覆盖。
|
||||
- 如果存在历史坏草稿,文档页应优先使用 Page Aggregate body。
|
||||
- 打开文档页后应把坏草稿覆盖或隔离,不让其继续污染后续打开。
|
||||
- 推荐策略:
|
||||
- 当 bootstrap contract 是 `mnote.page_aggregate.v1` 时,优先采用 bootstrap content。
|
||||
- 对与 mindmap object key 匹配的草稿直接隔离,不参与文档页正文恢复。
|
||||
- 对历史坏草稿只允许在同一 `ObjectIdentity` 下恢复,不允许跨 `page:index` 与 `resource:mindmap` 恢复。
|
||||
|
||||
- [x] mindmap 保存只走 mindmap command。
|
||||
- 保存命令:`mindmap.command.apply` 或当前正式 mindmap put/apply 链。
|
||||
- 禁止触发 `page.body.save`。
|
||||
- 推荐检查点:
|
||||
- `rust/crates/mnote-web/src/routes/mindmap_api.rs`
|
||||
- `rust/crates/mnote-web/src/transport/convex.rs`
|
||||
- `wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts`
|
||||
- 负向测试:mindmap object editor 保存期间拦截或记录网络请求,不能出现 `/api/documents/save` 或 canonical `page.body.save`。
|
||||
|
||||
### 3.3 真实网页 smoke
|
||||
|
||||
- [x] 更新 `scripts/task169-mindmap-realtime-smoke.js`。
|
||||
- 新建测试页面。
|
||||
- 插入或生成 mindmap block。
|
||||
- 从文件树点击本轮 `documentId + mindmapId` 对应 mindmap asset。
|
||||
- 断言进入 mindmap object editor,而不是 `index.md`。
|
||||
- 输入长中文并保存。
|
||||
- 切到其他页面。
|
||||
- 再点击原页面 `index.md`。
|
||||
- 断言 `index.md` 仍显示 Page Aggregate body。
|
||||
- 再打开 mindmap asset。
|
||||
- 断言长中文仍存在。
|
||||
- 测试数据前缀:`TEST-T169-MINDMAP-OBJECT-<timestamp>`。
|
||||
- 必须记录并断言:
|
||||
- `documentId`
|
||||
- `mindmapId`
|
||||
- mindmap object editor URL 或 tab identity
|
||||
- `index.md` 返回 URL
|
||||
- 长中文内容保存后的重开结果
|
||||
|
||||
- [x] 负向断言。
|
||||
- 点击 mindmap asset 后不得调用 `page.body.save`。
|
||||
- 打开 `index.md` 后不得加载 `__mindmap_object__` 草稿。
|
||||
- mindmap live refresh 不得在长中文编辑过程中重挂载或覆盖输入。
|
||||
- 修复前失败时 bug 留在 `bugs/05-editor-mainline/process/5-10-mindmap-filetree-index-single-truth-split-v1.md`;本轮通过后已移动到 done。
|
||||
|
||||
### 3.4 P0 建议实施顺序
|
||||
|
||||
- [x] Step 1:先补 smoke 红灯。
|
||||
- 修改:`scripts/task169-mindmap-realtime-smoke.js`。
|
||||
- 目标:当前实现应能暴露 `index.md` 与 mindmap object editor 身份混淆或草稿污染风险。
|
||||
- 运行:`node scripts/task169-mindmap-realtime-smoke.js`。
|
||||
- 预期:修复前至少一个负向断言失败,或者脚本明确输出当前路径仍有污染风险。
|
||||
|
||||
- [x] Step 2:标记 mindmap object editor 身份。
|
||||
- 修改:`rust/crates/mnote-web/src/routes/mindmap_shell.rs`、`rust/crates/mnote-web/src/ssr/pages/mindmap.rs`。
|
||||
- 目标:`/mindmap/{documentId}/{mindmapId}` 输出明确 object editor contract,不能被识别为 Page Aggregate body。
|
||||
- 验收:页面 DOM 能被 smoke 定位到 `data-mnote-object-editor="mindmap"` 或等价稳定标记。
|
||||
|
||||
- [x] Step 3:隔离 mindmap 草稿 key。
|
||||
- 修改:`rust/spikes/leptos-tiptap-spike/src/lib.rs`。
|
||||
- 目标:mindmap standalone 使用 `__mindmap_object__:{documentId}:{mindmapId}`;文档页使用 `page:index:{documentId}` 或现有页面草稿 key,但不得互读。
|
||||
- 验收:smoke 检查 localStorage 中 mindmap key 与 page key 分离。
|
||||
|
||||
- [x] Step 4:确认保存命令隔离。
|
||||
- 修改:优先不改实现,先用测试确认;如失败,再收口 `mindmap_api.rs` / `documents.rs` / transport adapter。
|
||||
- 目标:mindmap 保存只走 `mindmap.command.apply`,`index.md` 保存只走 `page.body.save`。
|
||||
- 验收:网络请求和 command result 中的 canonical command 分别正确。
|
||||
|
||||
- [x] Step 5:跑完整 smoke 并补 bug 证据。
|
||||
- 运行:`node scripts/task169-mindmap-realtime-smoke.js`。
|
||||
- 预期:长中文编辑、保存、切页、回 `index.md`、再开 mindmap 全链路通过。
|
||||
|
||||
## 4. P1:Object Tab Host 最小合同
|
||||
|
||||
### 4.1 Tab Identity
|
||||
|
||||
- [x] 定义主编辑区 tab identity。
|
||||
- `page:index:{documentId}`
|
||||
- `resource:mindmap:{documentId}:{mindmapId}`
|
||||
- `resource:onlyoffice:{documentId}:{assetId}`
|
||||
- `resource:attachment:{documentId}:{assetId}`
|
||||
- `resource:code:{documentId}:{assetId}`
|
||||
|
||||
- [x] 每个 tab 必须声明:
|
||||
- `objectKind`
|
||||
- `documentId`
|
||||
- `blockId`
|
||||
- `assetId`
|
||||
- `title`
|
||||
- `dirtyState`
|
||||
- `saveCommand`
|
||||
- `closeBehavior`
|
||||
|
||||
### 4.2 Tab 打开规则
|
||||
|
||||
- [x] `index.md` row 打开 `page:index:{documentId}`。
|
||||
- [x] mindmap row 打开 `resource:mindmap:{documentId}:{mindmapId}`。
|
||||
- [x] OnlyOffice row 打开 `resource:onlyoffice:{documentId}:{assetId}`。
|
||||
- [x] 代码附件 row 打开 `resource:code:{documentId}:{assetId}`。
|
||||
- [x] 普通附件 row 打开 `resource:attachment:{documentId}:{assetId}`。
|
||||
- 输入来源只能是 `4-24` 的 file tree row `resourceMeta.objectIdentity` 或等价字段。
|
||||
- 如果字段缺失,允许临时由 `resourceKind + documentId + assetId` 推导,但必须在代码注释或测试名中标明是兼容推导,不得作为新真源。
|
||||
|
||||
### 4.3 Tab 保存规则
|
||||
|
||||
- [x] Page tab 保存只调用 `page.body.save`。
|
||||
- [x] Mindmap tab 保存只调用 `mindmap.command.apply`。
|
||||
- [x] OnlyOffice tab 保存只通过 OnlyOffice callback / forcesave。
|
||||
- [x] Code tab 保存只写对应 asset。
|
||||
- [x] Attachment preview tab 默认无正文保存行为。
|
||||
- 负向规则:任一 resource tab 保存不得调用 `page.body.save`,除非该操作明确修改的是 Page Aggregate body 中的引用 block。
|
||||
|
||||
### 4.4 P1 建议实施顺序
|
||||
|
||||
- [x] Step 1:定义 tab identity 类型。
|
||||
- 首选落点:`rust/crates/core-protocol/src/kernel.rs` 或 `rust/crates/core-protocol/src/editor/model.rs`。
|
||||
- 前端适配落点:`wolai-frontend/src/components/sidebar/tree-shell-dom-model.ts`、`wolai-frontend/src/components/sidebar/sidebar-navigation.ts`。
|
||||
|
||||
- [x] Step 2:让 file tree open intent 携带 tab identity。
|
||||
- 修改:`rust/crates/mnote-web/src/routes/tree.rs`、`rust/crates/mnote-web/src/ssr/pages/layout.rs`。
|
||||
- 验收:`tree.asset.open` detail 中包含 `objectIdentity` 或可无歧义生成 object identity 的字段。
|
||||
|
||||
- [x] Step 3:主编辑区 host 消费 tab identity。
|
||||
- 初期允许只有 `page:index` 与 `resource:mindmap` 两类 tab。
|
||||
- 不要求一次完成 OnlyOffice / code / attachment 的 UI,但必须保留类型位。
|
||||
|
||||
- [x] Step 4:补 tab identity 单测。
|
||||
- 建议测试文件:`wolai-frontend/src/components/sidebar/sidebar-navigation.test.ts`、`wolai-frontend/src/components/sidebar/tree-shell-host.test.tsx`。
|
||||
- 断言:`index.md` 与 mindmap asset 打开生成不同 tab identity。
|
||||
|
||||
## 5. P2:Page Aggregate 与 Block-Asset Relation
|
||||
|
||||
- [x] 页面正文中的 mindmap block attrs 必须持有稳定 `mindmapId`。
|
||||
- [x] Page Aggregate 输出 mindmap block 与 resource relation。
|
||||
- [x] File Tree mindmap row 来源于同一 relation。
|
||||
- [x] 删除 mindmap block 时,资源关系必须进入 detach / archive 计划。
|
||||
- [x] 删除 mindmap asset 时,页面正文 block 必须进入明确处理计划:删除 block、保留占位、或标记资源缺失,不能静默断链。
|
||||
- 优先落点:`rust/crates/core-protocol/src/page_aggregate.rs`、`rust/crates/bridge-runtime/src/lib.rs`、`wolai-frontend/src/lib/documents/tiptap-content-converter.ts`。
|
||||
- 验收:同一个 `mindmapId` 能同时从 Page Aggregate block attrs 与 File Tree resource row 追踪到。
|
||||
|
||||
## 6. P3:OnlyOffice / 附件 / 插件对象推广
|
||||
|
||||
- [x] OnlyOffice 附件打开复用 Object Tab identity。
|
||||
- [x] 代码附件编辑复用 Object Tab identity。
|
||||
- [x] 普通附件预览复用 Object Tab identity。
|
||||
- [x] 插件型对象只能通过 block-asset relation 接入页面正文,不能把插件 runtime data 当 Page Aggregate body。
|
||||
- OnlyOffice 参考落点:`rust/crates/mnote-web/src/routes/onlyoffice.rs`、`scripts/task174-rust-onlyoffice-attachment-open-smoke.js`。
|
||||
- 上传 / 普通附件参考落点:`rust/crates/mnote-web/src/routes/media.rs`、`scripts/task175-rust-upload-entry-smoke.js`。
|
||||
|
||||
## 7. 验收命令
|
||||
|
||||
最低验收顺序:
|
||||
|
||||
```bash
|
||||
node scripts/task169-mindmap-realtime-smoke.js
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web mindmap -- --nocapture
|
||||
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web documents_save_route_executes_page_body_save_command -- --nocapture
|
||||
```
|
||||
|
||||
扩展验收:
|
||||
|
||||
```bash
|
||||
pnpm --dir wolai-frontend test -- sidebar-navigation.test.ts tree-shell-host.test.tsx
|
||||
node scripts/task174-rust-onlyoffice-attachment-open-smoke.js
|
||||
node scripts/task175-rust-upload-entry-smoke.js
|
||||
```
|
||||
|
||||
如本地服务未启动,先执行:
|
||||
|
||||
```bash
|
||||
npm run desktop:hot
|
||||
```
|
||||
|
||||
真实浏览器 smoke 必须使用默认测试账号 `mnote.e2e@example.com` / `MnoteE2E123!`,并优先从 `http://localhost:3000/auth` 的“测试账号快速登录”进入。
|
||||
|
||||
## 8. Done Gate
|
||||
|
||||
本文移动到 `done/` 前必须满足:
|
||||
|
||||
- [x] 当前 mindmap bug 文档 `5-10` 可移动到 `bugs/05-editor-mainline/done/`。
|
||||
- [x] 真实浏览器 smoke 证明 mindmap 文件树点击、长中文编辑、保存、切页、回 `index.md`、再开 mindmap 全链路正常。
|
||||
- [x] `index.md`、mindmap、OnlyOffice、附件的 object identity 已在打开路径和保存路径中区分。
|
||||
- [x] 至少 mindmap 与 `index.md` 已完成草稿隔离。
|
||||
- [x] 文件树打开行为不再让 asset 伪装成页面正文。
|
||||
|
||||
## 9. 完成证据
|
||||
|
||||
- Object identity:`rust/crates/core-protocol/src/kernel.rs` 定义 `KernelObjectIdentity`,file tree projection 与 DOM 传递 `page/index/mindmap/only_office/code/attachment` identity。
|
||||
- Mindmap object editor:`rust/crates/mnote-web/src/ssr/pages/mindmap.rs` 输出 `data-mnote-object-editor="mindmap"` 与 `data-mnote-object-identity="resource:mindmap:{documentId}:{mindmapId}"`。
|
||||
- 草稿隔离:`rust/crates/mnote-web/src/routes/mindmap_shell.rs` 使用 `__mindmap_object__:{doc}:{mindmap}` bootstrap identity;`rust/spikes/leptos-tiptap-spike/src/lib.rs` 的 `standalone_mindmap_object_uses_isolated_draft_identity` 通过。
|
||||
- 保存隔离:`cargo test -p mnote-web mindmap -- --nocapture` 与 `cargo test -p mnote-web documents_save_route_executes_page_body_save_command -- --nocapture` 通过。
|
||||
- 真实 smoke:`node scripts/task169-mindmap-realtime-smoke.js` 通过,覆盖文件树 mindmap 打开、长中文编辑、保存、切页、回 `index.md`、再打开 mindmap。
|
||||
|
||||
## 10. 完成后回填
|
||||
|
||||
实现完成后需要回填以下位置:
|
||||
|
||||
- `bugs/05-editor-mainline/done/5-10-mindmap-filetree-index-single-truth-split-v1.md`:已补真实修复证据并移动到 `done/`。
|
||||
- `design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md`:已标记 `ObjectIdentity` 与 file tree row contract 被编辑区消费。
|
||||
- `design/10-review/05-tree.md`:补最终采用的 object editor / object tab 方案摘要。
|
||||
@@ -0,0 +1,475 @@
|
||||
# 6 [process] Mindmap Phase 6 KMind/simple-mind-map Parity Detail Checklist v1
|
||||
|
||||
> 日期:2026-05-11
|
||||
>
|
||||
> 当前阶段:Phase 6 `leptos-mindmap` 已完成真实 `simple-mind-map` runtime + Leptos/Rust floating overlay shell 的第一轮收口;本 checklist 是后续细节优化入口。
|
||||
>
|
||||
> 执行规则:每完成一个节点,必须基于代码、测试、截图或人工复核证据勾选对应项。未验证的实现不得勾选。
|
||||
|
||||
## 0. 目标与结论
|
||||
|
||||
**目标:**把当前 mindmap shell 从“可运行的 Leptos/Rust 浮动 UI 壳”推进到接近 KMind 与 simple-mind-map 官方编辑器的工作台体验。
|
||||
|
||||
当前不需要改变 Phase 6 的大方向:
|
||||
|
||||
```text
|
||||
Rust kernel truth
|
||||
-> mindmap.simple_mind_map_scene.v1
|
||||
-> leptos-tiptap NodeView
|
||||
-> simple-mind-map runtime
|
||||
-> Leptos/Rust floating UI shell
|
||||
-> command bridge / compatPayload.patch
|
||||
```
|
||||
|
||||
本轮要修正的是 UI 与交互细节,而不是回退到以下路线:
|
||||
|
||||
- 不直接嵌入完整 Vue `lx-doc/mind-map` 应用。
|
||||
- 不恢复旧 React `MindmapBlock.tsx` 为 3000 文档页默认主链。
|
||||
- 不启动 Rust-native renderer 重写。
|
||||
- 不把 `simple-mind-map` runtime data 保存为 canonical truth。
|
||||
- 不把 TypeScript NodeView 扩写成长期 toolbar/sidebar/navigator UI 框架;长期 UI shell 继续归到 Leptos/Rust。
|
||||
|
||||
## 1. 本次取证结论
|
||||
|
||||
### 1.1 当前实现差距
|
||||
|
||||
已对照:
|
||||
|
||||
- 当前实现截图:`/mnt/Data1T/mnote/tmp/image copy 80.png`
|
||||
- KMind 目标截图:`/mnt/Data1T/mnote/tmp/image copy 81.png`
|
||||
- 隐藏 chrome 目标截图:`/mnt/Data1T/mnote/tmp/image copy 82.png`
|
||||
- 右侧详细设置栏目标截图:`/mnt/Data1T/mnote/tmp/image copy 83.png`
|
||||
|
||||
当前主要差距:
|
||||
|
||||
- Toolbar 当前视觉上仍是三行:`rust/spikes/leptos-tiptap-spike/src/lib.rs` 中 `.mnote-mindmap-command-toolbar` 与 `.mnote-mindmap-toolbar-primary` 都允许 `flex-wrap: wrap`,并且每个分组都带文字分组名,导致不可能稳定保持 KMind/simple-mind-map 的单行工具组。
|
||||
- Toolbar 当前没有真正的 `更多` 溢出模型。lx-doc `Toolbar.vue` 通过测量工具栏宽度,把可见按钮放入 `horizontalList`,把溢出按钮放入 `verticalList`,这比单纯 CSS 横向滚动更接近目标。
|
||||
- 当前没有完整全屏状态。lx-doc `Fullscreen.vue` 同时提供“全屏查看”和“全屏编辑”,并在 fullscreen change 后调用 `mindMap.resize()`。
|
||||
- 当前没有“鼠标移出后隐藏 chrome,鼠标移入不自动恢复,点击才恢复”的状态机。KMind changelog 明确提到 floating toolbar 会在特定场景自动隐藏,并推荐结合 Zen mode 获得更大的编辑视图。
|
||||
- 当前右侧栏只是窄 tab + 148px 简化 body;KMind/simple-mind-map 是“右侧触发条 + 可隐藏把手 + 约 300px 详细抽屉”,结构面板中有布局卡片,设置项密度明显更高。
|
||||
- 当前 navigator 是文字按钮和常驻搜索输入框;目标是底部右侧图标工具条,搜索按需展开,全屏、小地图、只读、缩放、鼠标行为、设置等是同一条浮动控件。
|
||||
|
||||
### 1.2 参考实现证据
|
||||
|
||||
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/Toolbar.vue`
|
||||
- `computeToolbarShow()` 根据宽度计算单行 `horizontalList` 与 `verticalList`。
|
||||
- `showMoreBtn` 显示 `更多` popover。
|
||||
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/ToolbarNodeBtnList.vue`
|
||||
- 记录 toolbar action、禁用状态、active node 规则和 runtime command。
|
||||
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/SidebarTrigger.vue`
|
||||
- 右侧触发条有 `show` 状态和 `toggleShowBtn` 隐藏把手。
|
||||
- 激活 panel 后容器从 `right: 0` 推到 `right: 305px`。
|
||||
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/NavigatorToolbar.vue`
|
||||
- 底部 navigator 是图标工具条,不是常驻表单。
|
||||
- 包含回根、搜索、鼠标行为、小地图、只读、全屏、缩放、暗色、源码、演示等入口。
|
||||
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/pages/Edit/components/Fullscreen.vue`
|
||||
- 区分 `fullscreenShow` 和 `fullscreenEdit`。
|
||||
- `design/05-editor-mainline/reference-code/lx-doc/mind-map/src/config/zh.js`
|
||||
- `sidebarTriggerList` 包含 `nodeStyle/baseStyle/theme/structure/outline/shortcutKey`。
|
||||
- `design/05-editor-mainline/reference-code/kmind-plugin/README_en_US.md`
|
||||
- 明确提到 desktop floating toolbar、Zen mode、自动隐藏 UI、全局配置。
|
||||
|
||||
- [x] 已完成当前实现、截图和参考代码的首轮取证。
|
||||
|
||||
## 2. 文件职责边界
|
||||
|
||||
### 2.1 主要运行代码
|
||||
|
||||
- `rust/spikes/leptos-tiptap-spike/src/lib.rs`
|
||||
- 当前 Leptos/Rust shell、CSS、mount/unmount、toolbar/sidebar/navigator 渲染集中在此。
|
||||
- 下一阶段应优先把 mindmap shell 拆出到专门模块,避免 `lib.rs` 继续膨胀。
|
||||
- `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
|
||||
- 当前 action、toolbar group、sidebar panel、navigator item、context menu 的 schema 来源。
|
||||
- 下一阶段需要扩展为可表达单行 toolbar、溢出、全屏、chrome visibility、详细 panel controls 的中立 schema。
|
||||
- `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
|
||||
- UI action 到 runtime/local/kernel/compat 的映射。
|
||||
- 下一阶段需要补全 `fullscreen`、`toggleChrome`、`toggleSidebar`、`toggleSearch`、`setScale`、`setMouseBehavior` 等 local view action。
|
||||
- `wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts`
|
||||
- 当前只覆盖 active node、readonly、capability、disabled state。
|
||||
- 下一阶段需要补 chrome visibility、fullscreen、sidebar open、search open、toolbar overflow、navigator active state。
|
||||
- `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts`
|
||||
- 当前 runtime bridge。
|
||||
- 下一阶段需要确保 fullscreen/resize、search、minimap、scale input、sidebar style 写入都能通过 bridge 安全触达 runtime。
|
||||
- `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
|
||||
- projection/command endpoint 与 adapter 初始化。
|
||||
- 下一阶段继续保持薄桥接,不承载 UI 细节。
|
||||
- `scripts/task166-mindmap-phase6-block-smoke.js`
|
||||
- 当前 Phase 6 smoke。
|
||||
- 下一阶段建议新增或扩展为 KMind parity smoke,保存对齐截图证据。
|
||||
|
||||
### 2.2 建议新增/拆分文件
|
||||
|
||||
- Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell.rs`
|
||||
- Leptos/Rust shell 组件、状态结构、事件派发。
|
||||
- Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell_style.rs` 或保留同模块内常量
|
||||
- 如果现有 CSS 继续增长,单独收口 mindmap shell 样式字符串。
|
||||
- Create: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
- 专门覆盖 toolbar 单行、全屏、chrome hide、右侧抽屉、截图对比。
|
||||
- Output: `tmp/task167-mindmap-kmind-parity-smoke/*.png`
|
||||
- 保存每个视觉验收节点截图。
|
||||
|
||||
## 3. 状态模型先行
|
||||
|
||||
后续 UI 不应继续靠 CSS 和零散按钮状态拼凑。先把 shell state 定义清楚。
|
||||
|
||||
### Task 1: 扩展 Mindmap Shell State Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-state.test.ts`
|
||||
- Modify/Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell.rs`
|
||||
|
||||
- [x] 定义 `MindmapChromeVisibilityState`:
|
||||
- `visible`
|
||||
- `hiddenByPointerLeave`
|
||||
- `hiddenByToggle`
|
||||
- `hiddenByFullscreen`
|
||||
- [x] 定义 `MindmapToolbarOverflowState`:
|
||||
- `availableWidth`
|
||||
- `visibleActionIds`
|
||||
- `overflowActionIds`
|
||||
- `moreOpen`
|
||||
- [x] 定义 `MindmapFullscreenState`:
|
||||
- `mode: "none" | "canvas" | "page"`
|
||||
- `isFullscreen`
|
||||
- `target: "mindmap-root" | "document-body"`
|
||||
- [x] 定义 `MindmapSidebarState`:
|
||||
- `triggerVisible`
|
||||
- `panelOpen`
|
||||
- `activePanelId`
|
||||
- `drawerWidth`
|
||||
- `collapsedByToggle`
|
||||
- [x] 定义 `MindmapNavigatorState`:
|
||||
- `searchOpen`
|
||||
- `minimapOpen`
|
||||
- `readonly`
|
||||
- `zoomPercent`
|
||||
- `mouseBehavior`
|
||||
- [x] TS 单测覆盖:鼠标移出后为 `hiddenByPointerLeave`;再次 mouseenter 不恢复;点击画布或点击显式恢复按钮才回到 `visible`。
|
||||
- [x] TS 单测覆盖:sidebar toggle 隐藏触发条后,不会清空 `activePanelId`;再次显示后保留最近面板。
|
||||
- [x] Rust/Leptos shell options 能接收上述 state,并向 DOM 输出稳定 `data-*` 标记,供 smoke 断言。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-ui-state`
|
||||
- 2026-05-11 通过:`mindmap-ui-state.test.ts` 6 项通过;同时复核 `npm run typecheck`、`npm run build`、`cargo check`、`node scripts/build-leptos-tiptap-island.js` 均通过。
|
||||
|
||||
## 4. 单行 Toolbar 与更多菜单
|
||||
|
||||
目标:对齐 `/mnt/Data1T/mnote/tmp/image copy 81.png` 与 lx-doc `Toolbar.vue`,主 toolbar 只占一行;溢出进入 `更多` 菜单;不要回到当前 `/mnt/Data1T/mnote/tmp/image copy 80.png` 的三行状态。
|
||||
|
||||
### Task 2: 设计单行 Toolbar Schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.test.ts`
|
||||
|
||||
- [x] 为 toolbar action 增加 `iconKey`、`shortLabel`、`longLabel`、`priority`、`overflowGroup`。
|
||||
- [x] 移除默认渲染中强依赖的分组文字标签;分组仅作为视觉分隔线和溢出归类,不占据单行宽度。
|
||||
- [x] 把 import/export/historyRecord 规划为右侧独立 toolbar cluster,避免和节点编辑按钮互相挤压。
|
||||
- [x] 增加 `more` 虚拟 action,只有存在 `overflowActionIds` 时显示。
|
||||
- [x] 单测断言默认 toolbar 主按钮顺序与 lx-doc/KMind 对齐:undo、redo、editNode、insertSiblingAfter、deleteNode、insertChild、tag、hyperlink、note、image、icon、summary、associativeLine、formula、more。
|
||||
- [x] 单测断言 action id 不重复,且每个可见 action 都能映射到 action map。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-ui-schema mindmap-action-map`
|
||||
- 2026-05-11 通过:`mindmap-ui-schema.test.ts` 11 项通过;同轮 `mindmap-action-map.test.ts` 4 项通过。
|
||||
|
||||
### Task 3: 实现 Leptos 单行 Toolbar Layout
|
||||
|
||||
**Files:**
|
||||
- Modify/Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell.rs`
|
||||
- Modify: `rust/spikes/leptos-tiptap-spike/src/lib.rs`
|
||||
- Modify: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
|
||||
- [x] Toolbar CSS 改为单行:`flex-wrap: nowrap`,固定按钮宽高,禁用 group 内换行。
|
||||
- [x] 顶部 toolbar 分为左/中/右 cluster:主编辑工具、导入导出/历史、更多。
|
||||
- [x] 用 `ResizeObserver` 或等价测量机制计算可见按钮与溢出按钮;窄宽度下不允许撑成第二行。
|
||||
- [x] `更多` 点击后打开浮层菜单,菜单内纵向列出溢出 action。
|
||||
- [x] `更多` 菜单失焦、点击菜单项或按 Escape 后关闭。
|
||||
- [x] 按钮视觉从“英文 icon 名 + 中文文字”修正为图标主导、短文字辅助;不得把 `palette/sliders/layout` 这类内部 icon key 作为可见正文。
|
||||
- [x] Smoke 断言 toolbar 高度不超过 72px,`toolbarRows <= 1` 或等价 DOM 断言通过。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/01-toolbar-single-row.png`,肉眼能看到 toolbar 单行而不是三行。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/leptos-tiptap/tiptap && npm run typecheck && npm run build`
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && node scripts/build-leptos-tiptap-island.js`
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task167-mindmap-kmind-parity-smoke.js --stage toolbar`
|
||||
- 2026-05-11 通过:桌面 toolbar `height=56`、`toolbarRows=1`;窄宽度 toolbar `height=56`、`toolbarRows=1`,并产生 `overflowActions`;Escape 后 `moreOpen=false` 且 `toolbarRows=1`。
|
||||
|
||||
## 5. 全屏与大画布模式
|
||||
|
||||
目标:至少提供 KMind/simple-mind-map 级别的全屏按钮与状态。优先做 block/canvas fullscreen,随后再补 page fullscreen。
|
||||
|
||||
### Task 4: 增加 Fullscreen Action Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-action-map.test.ts`
|
||||
|
||||
- [x] 新增 action:`fullscreenCanvas`,对应 lx-doc 的“全屏查看”。
|
||||
- [x] 新增 action:`fullscreenPage`,对应 lx-doc 的“全屏编辑”,第一阶段先作为 schema/action contract 保留。
|
||||
- [x] 新增 action:`exitFullscreen`。
|
||||
- [x] `fullscreenCanvas/fullscreenPage/exitFullscreen` 均标记为 `localView`,不得产生 kernel command。
|
||||
- [x] `Fullscreen API` 不可用时,action disabled,并在 UI 有稳定 `data-disabled-reason="fullscreen-unavailable"`。
|
||||
- [x] 单测覆盖 fullscreen action 不依赖 active node,readonly 下仍可使用。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-action-map mindmap-ui-state`
|
||||
- 2026-05-11 通过:`mindmap-action-map.test.ts` 新增 fullscreen localView 合同与 readonly 断言;`mindmap-ui-state.test.ts` 断言 readonly/无 active node 下 fullscreen 仍可用。
|
||||
|
||||
### Task 5: 实现全屏按钮与 resize
|
||||
|
||||
**Files:**
|
||||
- Modify/Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell.rs`
|
||||
- Modify: `rust/spikes/leptos-tiptap-spike/src/lib.rs`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts`
|
||||
- Modify: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
|
||||
- [x] 底部 navigator 增加全屏图标按钮,默认显示 canvas fullscreen。
|
||||
- [x] 点击后对 `data-testid="mnote-mindmap-editor-root"` 或等价 canvas root 调用 Fullscreen API。
|
||||
- [x] 监听 `fullscreenchange`,退出时同步 shell state。
|
||||
- [x] fullscreen change 后调用 `mindMap.resize()` 或 bridge 暴露的 resize 方法,并居中或保持当前 view transform。
|
||||
- [x] 全屏时保持 toolbar/sidebar/navigator 浮动在画布上;不出现文档页滚动条错位。
|
||||
- [x] 全屏模式下 Escape 退出后,toolbar/sidebar/navigator 状态恢复到进入前状态。
|
||||
- [x] Smoke 断言点击全屏后 `document.fullscreenElement` 为 mindmap root 或其包含节点。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/02-fullscreen-canvas.png`。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task167-mindmap-kmind-parity-smoke.js --stage fullscreen`
|
||||
- 2026-05-11 通过:`fullscreenElementIsRoot=true`,`shellFullscreenActive=true`;Escape 退出后 `document.fullscreenElement === null` 且 `shellFullscreenActive=false`。
|
||||
|
||||
## 6. 鼠标移出隐藏 Chrome,点击恢复
|
||||
|
||||
目标:对齐 `/mnt/Data1T/mnote/tmp/image copy 82.png`。鼠标移出后隐藏 toolbar/sidebar/navigator 等菜单;鼠标重新移入不自动恢复;用户点击画布或显式按钮才恢复。
|
||||
|
||||
### Task 6: 实现 Chrome Visibility State Machine
|
||||
|
||||
**Files:**
|
||||
- Modify/Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell.rs`
|
||||
- Modify: `rust/spikes/leptos-tiptap-spike/src/lib.rs`
|
||||
- Modify: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
|
||||
- [x] 在 mindmap root 上监听 pointer enter/leave/click,不依赖 document 全局 hover。
|
||||
- [x] pointer leave root 后进入 `hiddenByPointerLeave`,隐藏 toolbar、sidebar drawer、sidebar trigger、navigator、minimap;count 可保留或按 KMind 目标隐藏,需在 smoke 固定判定。
|
||||
- [x] pointer enter root 不改变 `hiddenByPointerLeave`。
|
||||
- [x] 点击画布空白区或节点后恢复 `visible`。
|
||||
- [x] 点击恢复后不触发误插入、不改变 active node,除非点击目标本身就是节点选择。
|
||||
- [x] 打开 `更多` 菜单或右侧 drawer 时,pointer leave 先关闭弹层,再隐藏 chrome,避免残留孤立菜单。
|
||||
- [x] fullscreen 模式与 hidden state 组合时,退出 fullscreen 不强制显示 chrome,除非进入 fullscreen 前是 visible。
|
||||
- [x] Smoke 操作:移动鼠标到 root 外 -> 断言 toolbar/sidebar/navigator hidden;移动回 root -> 仍 hidden;点击 root -> visible。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/03-chrome-hidden-after-leave.png`。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/04-chrome-restored-after-click.png`。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task167-mindmap-kmind-parity-smoke.js --stage chrome-hide`
|
||||
- 2026-05-11 通过:`sceneChromeVisibility/shellChromeVisibility` 从 `visible -> hiddenByPointerLeave -> hiddenByPointerLeave -> visible`;toolbar/sidebar/navigator 与截图状态一致。
|
||||
- 2026-05-11 补充通过:`fullscreen` stage 已覆盖 `visible -> enter fullscreen -> exit -> visible` 与 `hiddenByPointerLeave -> enter fullscreen -> exit -> hiddenByPointerLeave`,退出 fullscreen 不再强制显示 chrome。
|
||||
|
||||
## 7. 右侧详细设置栏与隐藏把手
|
||||
|
||||
目标:对齐 `/mnt/Data1T/mnote/tmp/image copy 83.png`。右侧不只是简化 tab,而是完整 panel drawer;触发条可以隐藏;隐藏后有把手可恢复。
|
||||
|
||||
### Task 7: 扩展 Sidebar Schema 到详细控件
|
||||
|
||||
**Files:**
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.test.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-action-map.test.ts`
|
||||
|
||||
- [x] Sidebar panel 增加 `kind`:`nodeStyle`、`baseStyle`、`theme`、`structure`、`outline`、`shortcutKey`、`settings`。
|
||||
- [x] Sidebar option 增加控件类型:`button`、`swatch`、`segmented`、`slider`、`numberInput`、`select`、`layoutCard`、`treeItem`、`toggle`。
|
||||
- [x] `structure` panel 定义布局卡片:逻辑结构图、思维导图、组织结构图、目录组织图、时间轴、鱼骨图。
|
||||
- [x] `theme` panel 定义主题卡片,至少包含 classic、classic4/KMind-like、simple、dark 入口。
|
||||
- [x] `nodeStyle` panel 定义节点填充、文字颜色、字号、加粗、斜体、形状、边框、线条颜色、线条宽度。
|
||||
- [x] `baseStyle` panel 定义连线风格、曲线/直线、彩虹线条、背景、节点间距、概要样式。
|
||||
- [x] `outline` panel 从 projection/runtime 派生树,不允许成为第二套树真相。
|
||||
- [x] `settings` 或 `shortcutKey` panel 第一阶段可显示只读内容,但需要在 schema 中有位置。
|
||||
- [x] 单测覆盖:每个 sidebar option 要么有 actionId,要么是只读控件;可写控件必须有 compat path 或 kernel command 映射。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-ui-schema mindmap-action-map`
|
||||
- 2026-05-11 通过:`mindmap-ui-schema.test.ts` 13 项通过;同轮 `mindmap-action-map.test.ts` 5 项通过。
|
||||
|
||||
### Task 8: 实现右侧 Trigger、Drawer、隐藏把手
|
||||
|
||||
**Files:**
|
||||
- Modify/Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell.rs`
|
||||
- Modify: `rust/spikes/leptos-tiptap-spike/src/lib.rs`
|
||||
- Modify: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
|
||||
- [x] 右侧 trigger 宽度对齐 KMind/simple-mind-map:约 60px,图标在上、文字在下。
|
||||
- [x] trigger active 状态使用蓝色强调条或蓝色文字,不再用整块按钮边框作为主要视觉。
|
||||
- [x] drawer 打开时宽度约 300px,右侧固定,覆盖画布,不挤压 runtime。
|
||||
- [x] drawer 顶部包含 panel 标题和关闭按钮。
|
||||
- [x] trigger 左侧或 drawer 边缘提供隐藏把手,点击后隐藏整个 trigger。
|
||||
- [x] 隐藏后保留一个小把手;鼠标移入不自动展开,点击把手才恢复。
|
||||
- [x] 点击 active trigger:若 drawer 已打开同一 panel,则关闭 drawer;若点击不同 panel,则切换 drawer 内容。
|
||||
- [x] 结构 panel 的布局卡片使用稳定尺寸,不能因文字或 hover 导致布局跳动。
|
||||
- [x] 右侧 drawer 打开时,底部 navigator 与 drawer 不重叠;必要时 navigator 向左避让或保持在 drawer 左侧。
|
||||
- [x] Smoke 断言:打开 `structure` panel 后可见标题“结构”,可见至少 6 个布局卡片,存在关闭按钮和隐藏把手。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/05-sidebar-structure-drawer.png`。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/06-sidebar-hidden-handle.png`。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task167-mindmap-kmind-parity-smoke.js --stage sidebar`
|
||||
- 2026-05-11 通过:`drawerWidth=300`,`title=结构`,`structureCardCount=6`;close/hide/restore 均通过。
|
||||
|
||||
### Task 9: 打通右侧设置到 Runtime/Kernel/Compat
|
||||
|
||||
**Files:**
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/leptos-mindmap-adapter.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-command-diff.ts`
|
||||
- Modify: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
|
||||
- [x] `setLayout` 走 kernel command,并刷新 adapter projection。
|
||||
- [x] `setTheme` 走 kernel command,并刷新 adapter projection。
|
||||
- [x] 节点样式类 action 先走 runtime 预览,再以 `compatPayload.patch` 保存样式字段。
|
||||
- [x] 基础样式类 action 先走 runtime config/themeConfig,再以 `compatPayload.patch` 保存无法语义化字段。
|
||||
- [x] option 点击失败时显示 `command_failed` 或等价错误层,不能只改变 UI 本地状态。
|
||||
- [x] Smoke 点击结构卡片后 reload,断言 projection layout 保持。
|
||||
- [x] Smoke 点击主题卡片后 reload,断言 theme 保持。
|
||||
- [x] Smoke 点击节点样式后 reload,断言 compatPayload patch 可见或节点样式保留。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- mindmap-action-map simple-mind-map-bridge`
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task167-mindmap-kmind-parity-smoke.js --stage sidebar-actions`
|
||||
- 2026-05-11 通过:`sidebarActions.beforeReload/afterReload` 均为 `layout=mindMap`、`theme=dark`、`rootFillColor=#dbeafe`;同时修正 metadata-only 本地 `put` 误分流,统一回到 `mindmap.command.apply -> mnote-web server-side apply -> mindmaps.put` 主链。
|
||||
|
||||
## 8. 底部 Navigator 图标化与 MiniMap/Search
|
||||
|
||||
目标:对齐 KMind/simple-mind-map 底部右侧工具条。搜索不应长期占用输入框宽度;全屏、只读、小地图、缩放应统一成 icon/action。
|
||||
|
||||
### Task 10: Navigator Schema 与 UI 重排
|
||||
|
||||
**Files:**
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
|
||||
- Modify/Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell.rs`
|
||||
- Modify: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
|
||||
- [x] Navigator item 改为图标按钮为主:回根、搜索、小地图、只读/编辑、全屏、缩小、缩放值、放大、设置。
|
||||
- [x] 搜索默认只显示图标;点击后展开输入框;再次点击或 Escape 收起。
|
||||
- [x] 缩放值允许输入百分比;非法输入恢复上一次有效值。
|
||||
- [x] 小地图打开后显示在 navigator 上方或右下安全位置,不遮挡 drawer。
|
||||
- [x] 只读状态显示明确 active 状态,并同步 `mindMap.setMode("readonly" | "edit")`。
|
||||
- [x] Count 固定在左下角,文本保持简短:`字数 17`、`节点 4`。
|
||||
- [x] Smoke 断言默认 navigator 没有常驻搜索输入框;点击搜索后输入框出现。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/07-navigator-icons.png`。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/08-search-expanded.png`。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task167-mindmap-kmind-parity-smoke.js --stage navigator`
|
||||
- 2026-05-11 通过:默认 `searchOpen=false`、无常驻输入框;展开搜索后截图已保存;最终 `minimapOpen=true`、`readonly=true`、非法缩放输入恢复为 `100%`。
|
||||
|
||||
## 9. Context Menu 与快捷键基础对齐
|
||||
|
||||
目标:当前右键菜单已有第一阶段能力,但要对齐 KMind/simple-mind-map 的禁用状态、显示位置和隐藏行为。
|
||||
|
||||
### Task 11: Context Menu Parity
|
||||
|
||||
**Files:**
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-ui-schema.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
|
||||
- Modify/Create: `rust/spikes/leptos-tiptap-spike/src/mindmap_shell.rs`
|
||||
- Modify: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
|
||||
- [x] 区分节点右键菜单与画布右键菜单。
|
||||
- [x] 节点菜单包含插入子节点、插入同级、删除、展开/收起、概要、关联线、复制文本。
|
||||
- [x] 画布菜单包含回根、适应画布、搜索、只读切换、显示菜单。
|
||||
- [x] root/generalization 等特殊节点禁用不适用 action。
|
||||
- [x] 右键菜单打开时 pointer leave 不立即隐藏 chrome;点击菜单项、Escape、点击画布空白后关闭。
|
||||
- [x] 菜单位置不能超出 mindmap root 边界。
|
||||
- [x] Smoke 断言节点右键菜单与画布右键菜单的 action 列表不同。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/09-node-context-menu.png`。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task167-mindmap-kmind-parity-smoke.js --stage context-menu`
|
||||
- 2026-05-11 通过:节点菜单仅包含 node actions,root 节点 `insertSiblingAfter/deleteNode` 为 disabled;画布菜单为 `centerRoot/fitView/search/readonly/showMenu`;pointer leave 后菜单与 chrome 保持可见,Escape 后关闭。
|
||||
|
||||
## 10. KMind 视觉基线
|
||||
|
||||
目标:不要只实现功能按钮,要让默认观感接近 KMind/simple-mind-map 工作台。
|
||||
|
||||
### Task 12: 默认 Theme/Layout 视觉对齐
|
||||
|
||||
**Files:**
|
||||
- Modify: `rust/crates/bridge-runtime/src/lib.rs`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts`
|
||||
- Modify: `wolai-frontend/src/lib/mindmap/mindmap-projection.ts`
|
||||
- Modify: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
|
||||
- [x] 默认 root 节点使用红色/橙红色背景、白色粗体文字,接近 KMind 截图。
|
||||
- [x] 默认二级节点使用蓝色背景、白色文字。
|
||||
- [x] 分支主题使用蓝色文字,概要括号线使用红色或主题强调色。
|
||||
- [x] 默认 layout 选择与 KMind 截图一致的右向逻辑结构。
|
||||
- [x] 连线位置不再错位;节点中心线、概要括号线和子节点垂直中心对齐。
|
||||
- [x] Smoke 读取 canvas/svg 或截图像素,确认节点和连线非空、可见、位置不重叠。
|
||||
- [x] Screenshot:`tmp/task167-mindmap-kmind-parity-smoke/10-kmind-theme-baseline.png`。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task167-mindmap-kmind-parity-smoke.js --stage theme`
|
||||
- 2026-05-11 通过:runtime 默认 `layout=logicalStructure`、`theme=default`,`themeConfig` 生效为 root 红底白字、二级蓝底白字、分支蓝字、红色概要线;smoke 读取到 `nodeCount=5`、`pathCount=9`、`overlaps=0`。
|
||||
|
||||
## 11. 验证体系
|
||||
|
||||
### Task 13: 新增 KMind Parity Smoke
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
- Output: `tmp/task167-mindmap-kmind-parity-smoke/result.json`
|
||||
- Output: `tmp/task167-mindmap-kmind-parity-smoke/*.png`
|
||||
|
||||
- [x] 启动前检查 `http://127.0.0.1:3000/` 可用,不强制重启已存在服务。
|
||||
- [x] 使用默认测试账号登录。
|
||||
- [x] 新建或打开临时文档,插入 mindmap block。
|
||||
- [x] 断言 `simple-mind-map` runtime 存在,canvas rect 非零。
|
||||
- [x] 断言 Leptos/Rust shell 来源为 `data-ui-shell-source="leptos-rust-shell"`。
|
||||
- [x] 断言 toolbar 单行。
|
||||
- [x] 断言 fullscreen 按钮存在并可进入/退出。
|
||||
- [x] 断言 pointer leave 隐藏 chrome、pointer enter 不恢复、click 恢复。
|
||||
- [x] 断言右侧 drawer 可打开、关闭、隐藏 trigger、点击把手恢复。
|
||||
- [x] 断言 navigator 搜索按需展开,小地图可打开。
|
||||
- [x] 断言 reload 后 layout/theme/view/compat patch 不丢。
|
||||
- [x] 输出 `result.json`,至少包含 `ok`、`baseUrl`、`documentId`、`mindmapId`、`toolbarRows`、`fullscreenOk`、`chromeHideOk`、`sidebarDrawerOk`、`navigatorOk`、`screenshots`。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task167-mindmap-kmind-parity-smoke.js`
|
||||
- 2026-05-11 通过:默认执行 `all` stage,`result.json` 输出 `ok/baseUrl/documentId/mindmapId/toolbarRows/fullscreenOk/chromeHideOk/sidebarDrawerOk/navigatorOk/contextMenuOk/themeOk/reloadOk/screenshots`,截图 01-10 全量生成。
|
||||
|
||||
### Task 14: 回归现有 Phase 6 Smoke
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/task166-mindmap-phase6-block-smoke.js` only if needed
|
||||
|
||||
- [x] `task167` 新增后,`task166` 继续保持 runtime/projection/command/reload 基础验证,不与视觉 parity 重复过多。
|
||||
- [x] `task166` 不因 toolbar 单行、drawer、fullscreen 变化而误判旧 testid 缺失。
|
||||
- [x] `task166` 与 `task167` 输出目录分离。
|
||||
- [x] 验证:`cd /mnt/Data1T/mnote && node scripts/task166-mindmap-phase6-block-smoke.js`
|
||||
- 2026-05-11 通过:`task166` 保持基础链路验证,兼容 navigator 搜索折叠与 zoom 输入框;`result.json` 输出在 `tmp/task166-mindmap-phase6-block-smoke/`,与 `task167` 分离。
|
||||
|
||||
## 12. 执行顺序建议
|
||||
|
||||
建议按以下顺序执行,避免先做 CSS 后返工状态模型:
|
||||
|
||||
1. Task 1:状态模型。
|
||||
2. Task 2-3:单行 toolbar 与更多菜单。
|
||||
3. Task 4-5:全屏。
|
||||
4. Task 6:chrome hide/click restore。
|
||||
5. Task 7-9:右侧详细设置栏与持久化。
|
||||
6. Task 10:navigator 图标化。
|
||||
7. Task 11:context menu。
|
||||
8. Task 12:视觉主题与连线对齐。
|
||||
9. Task 13-14:smoke 与回归。
|
||||
|
||||
每次实现完成后,至少保存对应截图,再勾选节点。
|
||||
|
||||
## 13. 阶段验收标准
|
||||
|
||||
本 checklist 视为完成时,必须同时满足:
|
||||
|
||||
- [x] `/mnt/Data1T/mnote/tmp/task167-mindmap-kmind-parity-smoke/01-toolbar-single-row.png` 显示单行 toolbar。
|
||||
- [x] `/mnt/Data1T/mnote/tmp/task167-mindmap-kmind-parity-smoke/02-fullscreen-canvas.png` 显示全屏画布与浮动 chrome。
|
||||
- [x] `/mnt/Data1T/mnote/tmp/task167-mindmap-kmind-parity-smoke/03-chrome-hidden-after-leave.png` 显示鼠标移出后的隐藏状态。
|
||||
- [x] `/mnt/Data1T/mnote/tmp/task167-mindmap-kmind-parity-smoke/05-sidebar-structure-drawer.png` 显示详细右侧结构面板。
|
||||
- [x] `/mnt/Data1T/mnote/tmp/task167-mindmap-kmind-parity-smoke/07-navigator-icons.png` 显示图标化底部 navigator。
|
||||
- [x] `node scripts/task166-mindmap-phase6-block-smoke.js` 通过。
|
||||
- [x] `node scripts/task167-mindmap-kmind-parity-smoke.js` 通过。
|
||||
- [x] `pnpm test -- mindmap-ui-schema mindmap-action-map mindmap-ui-state simple-mind-map-bridge` 通过。
|
||||
- [x] `npm run typecheck && npm run build` 在 `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap` 通过。
|
||||
- [x] Rust `page.body.save` 主链保存 `mindmap` block 后,`/api/documents/content` 读回仍为 `type: "mindmap"`,不再退化成 `paragraph`。
|
||||
- 2026-05-12 通过:新增 `cargo test -p core-protocol preserves_mindmap_paragraph_placeholder`、`cargo test -p core-protocol tiptap_mindmap_placeholder_round_trip_preserves_mindmap_attrs`、`cargo test -p bridge-runtime documents_save_command_plan_preserves_mindmap_placeholder`;同时在真实 `http://127.0.0.1:3000` 上手工验证 `content[0].type === "mindmap"` 且 `pageSubtree.subtree.nodes[1].metadata.blockType === "mindmap"`。
|
||||
- [x] `GET /documents/{documentId}?workspaceId=...` 对有效导图文档返回 HTML 页面壳,而不是 `documents.content.get 未返回文档内容` 的错误 JSON。
|
||||
- 2026-05-12 通过:在前台 `mnote-web` 实例上重新创建临时导图文档后复测,返回 `200 text/html; charset=utf-8`,首屏 HTML 正常包含 document shell。
|
||||
- [x] 3000 文档页默认主链仍为 Leptos/Rust shell,不加载旧 React mindmap 主链。
|
||||
|
||||
## 14. 明确延期项
|
||||
|
||||
以下能力可参考 KMind,但不进入本 checklist 的完成条件:
|
||||
|
||||
- 多根节点。
|
||||
- MOC 模式。
|
||||
- 思源块预览、镜像块、PDF 标注跳转。
|
||||
- Freemind/XMind 全量导入导出。
|
||||
- 主题设计器与主题分享。
|
||||
- 完整快捷键自定义配置。
|
||||
- 演示模式。
|
||||
- Rust-native renderer。
|
||||
@@ -0,0 +1,362 @@
|
||||
# 9 [process] SiYuan 参考边界与可借鉴能力 v1
|
||||
|
||||
> 更新时间:2026-05-11
|
||||
>
|
||||
> 上位依据:
|
||||
> - `/mnt/Data1T/mnote/ARCHITECTURE.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/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
|
||||
>
|
||||
> 外部参考:
|
||||
> - `https://github.com/siyuan-note/siyuan`
|
||||
> - `https://raw.githubusercontent.com/siyuan-note/siyuan/master/README_zh_CN.md`
|
||||
> - `https://raw.githubusercontent.com/siyuan-note/siyuan/master/API_zh_CN.md`
|
||||
>
|
||||
> 本稿定位:
|
||||
> - 本稿是 `mnote` 的参考与借鉴边界稿。
|
||||
> - 本稿不是新的上位架构来源。
|
||||
> - 本稿不覆盖 `01-05` 当前主线优先级。
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份稿只回答一个问题:
|
||||
|
||||
> **思源笔记对当前 `mnote` 主线,哪些地方值得参考,哪些地方不应照搬。**
|
||||
|
||||
当前结论固定为:
|
||||
|
||||
> **思源更适合作为产品能力与交互参考,不适合作为 `mnote` 长期架构模板。**
|
||||
|
||||
原因不是思源做得不成熟,而是两边长期目标不同:
|
||||
|
||||
- 思源更接近 `本地优先工作空间 + 块文档 + Go kernel + TS/Electron 产品壳`
|
||||
- `mnote` 当前主线是 `tree-first graph kernel + Rust 持有语义 + Page Aggregate / Tree Realtime / Tree Command 收口`
|
||||
|
||||
因此,后续引用思源时必须先区分:
|
||||
|
||||
1. 是在参考产品层能力
|
||||
2. 还是在引入架构层真相
|
||||
|
||||
只有第一类默认允许,第二类默认不允许。
|
||||
|
||||
---
|
||||
|
||||
## 2. 先给结论
|
||||
|
||||
### 2.1 思源值得参考的层级
|
||||
|
||||
思源当前最值得参考的是:
|
||||
|
||||
- 块级引用、双向链接、反链、图谱、大纲这一整套产品能力的配套闭环
|
||||
- 属性 / 数据库视图的用户心智、操作颗粒度和投影形态
|
||||
- 本地优先工作区的数据组织、导入导出、资源目录和恢复路径
|
||||
- 大体量单机笔记产品的交互密度、功能面排布和“一个能力带一圈配套能力”的产品完成度
|
||||
- 编辑器相关的局部交互细节,以及导图插件这一类挂件的集成方式
|
||||
|
||||
### 2.2 思源不应成为 `mnote` 的长期架构模板
|
||||
|
||||
思源当前不应被拿来直接替代或覆盖:
|
||||
|
||||
- `tree-first graph kernel`
|
||||
- `Rust kernel` 的语义主导权
|
||||
- `mnote-web` 作为 `3000` 主执行面
|
||||
- `Page Aggregate` 作为页面域单一真相收口方向
|
||||
- `tree.*` 正式命令面与 `tree events` realtime 主链
|
||||
|
||||
一句话收口:
|
||||
|
||||
> **思源可以提供“功能长什么样”的答案,但不能替代 `mnote` 对“系统真相由谁持有”的既定答案。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么会觉得像思源
|
||||
|
||||
用户会感觉当前实现和思源接近,并不是错觉,主要有下面这些原因:
|
||||
|
||||
- 都是块式文档体验,而不是传统线性文档页
|
||||
- 都强调页面树、块引用、双向链接、嵌入、导图或挂件类能力
|
||||
- 都不是纯 Markdown 文件列表产品,而是更接近“知识对象 + 多视图”的产品
|
||||
- 都会同时出现页面、块、资源、搜索、反链、图谱、数据库视图这些能力面
|
||||
|
||||
但相似主要停留在产品表层,不等于底层事实源一致。
|
||||
|
||||
当前两边关键差异是:
|
||||
|
||||
- 思源偏 `workspace/data + .sy + API + 本地工作区`
|
||||
- `mnote` 偏 `kernel truth + projection + command + realtime stream`
|
||||
|
||||
这条差异决定了:
|
||||
|
||||
> **参考思源时,应优先借它的产品形态,不要把它的数据真相层和 API 哲学直接搬进来。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 思源当前可见的能力面
|
||||
|
||||
从公开仓库、README 和 API 可见,思源不是“只有一个块编辑器”,而是已经形成下面这些稳定能力面:
|
||||
|
||||
- 块:插入、更新、删除、移动、折叠、展开、块引用
|
||||
- 属性:块属性读写
|
||||
- SQL:查询与事务刷新
|
||||
- 属性视图 / 数据库视图:表格、看板、画廊等
|
||||
- 大纲、反链、图谱、搜索
|
||||
- 工作空间、文件树、资源文件、模板、插件、代码片段
|
||||
- 历史、同步、导出、剪藏、闪卡、OCR、AI、移动端与 Docker
|
||||
|
||||
这说明思源真正有参考价值的不是某个单点组件,而是:
|
||||
|
||||
> **当一个系统把“块”作为核心对象后,周边需要跟着长出来的整圈配套能力。**
|
||||
|
||||
---
|
||||
|
||||
## 5. 可直接借鉴的部分
|
||||
|
||||
## 5.1 块级引用不是单点功能,而是一整圈产品能力
|
||||
|
||||
思源把块级引用、双向链接、反链、搜索、图谱、大纲做成了互相咬合的一组能力。
|
||||
|
||||
对 `mnote` 的启发不是“做一个 `((block))` 就够了”,而是:
|
||||
|
||||
- 一旦有块引用,就应该有稳定的引用目标解析
|
||||
- 一旦有引用目标解析,就应该有反链和搜索投影
|
||||
- 一旦有反链和搜索投影,就应该考虑页面页头、阅读态、导图、AI 上下文如何共享这一组对象语义
|
||||
|
||||
这与当前 `mnote` 主线是相容的,因为这些都应该继续落到 Rust projection family,而不是前端各自维护一份块真相。
|
||||
|
||||
## 5.2 属性视图 / 数据库视图值得作为单独对象域评估
|
||||
|
||||
思源已经证明:
|
||||
|
||||
- 属性不是补充字段
|
||||
- 数据库视图也不是“在文档里画个表格”就结束
|
||||
|
||||
它更接近:
|
||||
|
||||
- 对象属性定义
|
||||
- 多视图投影
|
||||
- 排序 / 分组 / 过滤 / relation / rollup 一组相关语义
|
||||
|
||||
对 `mnote` 的启发是:
|
||||
|
||||
> **如果后续做属性视图,不应只把它当成编辑器里的一个特殊块,而应评估它是否需要独立的 kernel object / projection / command family。**
|
||||
|
||||
这里可以借思源的产品心智,但不要直接借它的存储组织。
|
||||
|
||||
## 5.3 本地工作区组织与导入导出心智值得参考
|
||||
|
||||
思源 README 明确公开了工作空间 `data/` 下的目录组织,例如:
|
||||
|
||||
- `assets`
|
||||
- `templates`
|
||||
- `snippets`
|
||||
- `plugins`
|
||||
- `public`
|
||||
- 文档与笔记本目录
|
||||
|
||||
这对 `mnote` 的参考价值主要在用户体验层:
|
||||
|
||||
- 本地导出时,哪些内容算“工作区资产”
|
||||
- 用户如何理解模板、资源、插件与公开文件
|
||||
- 发生故障时,用户该如何备份与迁移
|
||||
|
||||
这里适合作为:
|
||||
|
||||
- 本地文件夹体验
|
||||
- 导入导出 UX
|
||||
- 恢复与备份说明
|
||||
|
||||
的参考,不适合作为新的 canonical source。
|
||||
|
||||
## 5.4 导图与插件化挂件有参考意义
|
||||
|
||||
当前 `mnote` 已经在 `design/05-editor-mainline/reference-code/` 下保留了 `siyuan-kmind-plugin` 参考代码,这个方向是合理的。
|
||||
|
||||
对导图线的正确借法是:
|
||||
|
||||
- 参考思源插件 / KMind 的 UI、行为、挂件边界
|
||||
- 参考其与文档块、引用预览、搜索、资源插入的交互方式
|
||||
- 不复制它的思源宿主耦合
|
||||
|
||||
这与当前 `Phase 6` 已经明确的口径一致:
|
||||
|
||||
> **可以参考 KMind 的 UI 与行为,但不要复制它的思源插件耦合。**
|
||||
|
||||
## 5.5 “成熟单机笔记产品的密度”本身值得参考
|
||||
|
||||
思源的一个重要价值,不是某个 API,而是它已经证明:
|
||||
|
||||
- 用户愿意接受高密度功能面
|
||||
- 页面 / 树 / 搜索 / 反链 / 数据库 / 插件 / 导出 / 历史可以共存
|
||||
- 关键不在于功能少,而在于对象边界和入口是否清晰
|
||||
|
||||
这对 `mnote` 的启发是:
|
||||
|
||||
> **后续不需要因为主线在收口,就把能力面理解成必须长期极简;真正要避免的是语义散乱,而不是能力丰富。**
|
||||
|
||||
---
|
||||
|
||||
## 6. 不建议照搬的部分
|
||||
|
||||
## 6.1 不照搬 `.sy + 工作空间文件` 作为系统真相层
|
||||
|
||||
思源的数据组织很适合它自己的本地优先单机模型,但 `mnote` 当前已经明确:
|
||||
|
||||
- `tree-first graph kernel` 是长期对象真相层
|
||||
- `Convex` 继续保留为当前存储 / 实时 / 文件协作底座
|
||||
- `mnote-web` 与 Rust kernel 持有主执行语义
|
||||
|
||||
因此后续即使增加本地文件夹能力,也不能把:
|
||||
|
||||
- 工作空间目录结构
|
||||
- 导出文件形状
|
||||
- 调试缓存
|
||||
|
||||
误提升为新的系统真相层。
|
||||
|
||||
## 6.2 不把 SQL 暴露成长期核心产品契约
|
||||
|
||||
思源公开提供 SQL 查询接口,这很适合本地单机高级用户,但对 `mnote` 有明显风险:
|
||||
|
||||
- 会绕开 `projection` 与 `command` 边界
|
||||
- 会破坏页面域和树域单一真源收口
|
||||
- 会让 AI、CLI、前端和脚本各自形成第二套数据读取口径
|
||||
|
||||
所以对 `mnote` 来说,正确借法是:
|
||||
|
||||
- 借“高级查询能力”这个需求
|
||||
- 不借“把底层 SQL 直接暴露为长期主接口”这个做法
|
||||
|
||||
若未来需要高级查询,应优先考虑:
|
||||
|
||||
- kernel query family
|
||||
- projection query endpoint
|
||||
- 受控 DSL
|
||||
|
||||
而不是直接给业务面开放底层 SQL。
|
||||
|
||||
## 6.3 不把前端运行时做成第二语义中心
|
||||
|
||||
思源前端 `protyle` 与周边 TS runtime 很大,说明它有相当一部分产品组织与交互复杂度留在前端。
|
||||
|
||||
这对当前 `mnote` 不是该追的方向,因为你们当前最重要的事是:
|
||||
|
||||
- 继续收口 `Page Aggregate`
|
||||
- 继续收口 `tree command`
|
||||
- 继续收口 `tree realtime`
|
||||
|
||||
因此不应因为参考思源,就重新把:
|
||||
|
||||
- 标题语义
|
||||
- 页面设置语义
|
||||
- 引用解析语义
|
||||
- 数据库视图真相
|
||||
|
||||
重新扩散到前端壳层或 compat 层。
|
||||
|
||||
## 6.4 不默认接受它的单用户 / 本地优先假设
|
||||
|
||||
思源大量设计天然偏:
|
||||
|
||||
- 单机优先
|
||||
- 工作空间文件优先
|
||||
- 用户直接接触本地数据目录
|
||||
|
||||
而 `mnote` 当前仍保留:
|
||||
|
||||
- Convex 自托管底座
|
||||
- realtime 协作链
|
||||
- Rust Web `3000` 主入口
|
||||
|
||||
因此参考思源时必须先问:
|
||||
|
||||
> **这个能力是在单用户前提下成立,还是在当前 `mnote` 的协作 / realtime 前提下也能成立。**
|
||||
|
||||
只在前者成立的方案,默认不能直接进入主线。
|
||||
|
||||
---
|
||||
|
||||
## 7. 对 `mnote` 的建议落点
|
||||
|
||||
| 参考主题 | 是否建议借鉴 | 推荐落点 |
|
||||
| --- | --- | --- |
|
||||
| 块级引用 / 双向链接 / 反链 | 建议 | Rust query / projection family,统一到 page/tree 相关投影 |
|
||||
| 属性视图 / 数据库视图 | 建议 | 先做对象域评估,再决定 command / projection 边界 |
|
||||
| 图谱 / 大纲 / 搜索 | 建议 | 作为引用体系的配套视图,而不是孤立功能 |
|
||||
| 工作区导出 / 资源目录 / 模板心智 | 建议 | 本地文件夹、导出导入、恢复与帮助文档 |
|
||||
| 导图插件交互 | 建议 | 继续作为 `leptos-mindmap` 的参考行为层 |
|
||||
| `.sy` 文档真相层 | 不建议 | 不进入 `mnote` 主线 |
|
||||
| SQL 直接开放给产品层 | 不建议 | 改用 kernel query / projection / DSL |
|
||||
| 前端重语义运行时 | 不建议 | 继续把长期语义收口到 Rust kernel |
|
||||
| 单机假设下的 API 组织 | 谨慎 | 只借需求,不借真相层与执行面 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前优先级下的使用方式
|
||||
|
||||
考虑到当前 `mnote` 的第一优先级仍然是:
|
||||
|
||||
1. `Page Aggregate`
|
||||
2. `Tree Command Cutover`
|
||||
3. `Tree Realtime Event Stream`
|
||||
|
||||
所以本稿的执行口径应固定为:
|
||||
|
||||
### 8.1 允许用思源来校准需求
|
||||
|
||||
例如:
|
||||
|
||||
- 块引用之后,用户预期还需要什么
|
||||
- 反链页、图谱页、搜索页应该有哪些最小能力
|
||||
- 属性视图第一版做成什么形态才不失真
|
||||
- 导出与本地资源管理要不要显式工作区概念
|
||||
|
||||
### 8.2 不允许用思源来打断当前收口顺序
|
||||
|
||||
例如不能因为思源已有某个能力,就跳过:
|
||||
|
||||
- `Page Aggregate` 的主链闭环
|
||||
- `tree.*` 命令统一
|
||||
- `/api/tree/events` live cache 收口
|
||||
|
||||
否则会把参考稿误用成新的需求插队入口。
|
||||
|
||||
### 8.3 参考思源时优先写成“能力映射”,不要写成“照搬实现”
|
||||
|
||||
后续如果再新增思源相关设计稿,优先采用:
|
||||
|
||||
- “思源某能力对 `mnote` 的需求映射”
|
||||
- “思源某交互对 `mnote` 的行为对照”
|
||||
|
||||
而不是:
|
||||
|
||||
- “把思源 API / 数据格式搬进来”
|
||||
- “按思源目录结构重建 `mnote`”
|
||||
|
||||
---
|
||||
|
||||
## 9. 当前建议的后续拆分
|
||||
|
||||
如果后续继续使用思源作为参考,建议只沿下面几个方向继续出稿:
|
||||
|
||||
1. `块引用 / 反链 / 图谱 / 搜索` 的能力映射稿
|
||||
2. `属性视图 / 数据库视图` 的对象域评估稿
|
||||
3. `本地工作区 / 导入导出 / 资源目录` 的 UX 参考稿
|
||||
4. `导图插件 / 挂件 / 引用预览` 的行为对照稿
|
||||
|
||||
不建议出的稿:
|
||||
|
||||
1. “按思源重写 `mnote` 数据层”
|
||||
2. “引入思源式 SQL 主接口”
|
||||
3. “以思源前端 runtime 替代当前 Rust 主线”
|
||||
|
||||
---
|
||||
|
||||
## 10. 一句话收口
|
||||
|
||||
当前 `mnote` 对思源的正确态度应固定为:
|
||||
|
||||
> **把思源当成成熟块知识产品的参考样本库,重点吸收它的能力面、交互完成度和配套闭环;但 `mnote` 的长期事实源、命令面、projection 与 realtime 主链,继续严格沿 `tree-first graph kernel` 推进。**
|
||||
@@ -0,0 +1,119 @@
|
||||
# Rust Kernel / Web 实现偏差审查
|
||||
|
||||
## 范围
|
||||
|
||||
本次只审查 Rust kernel / protocol / bridge-runtime / mnote-web / storage-convex-bridge 与当前设计主线的一致性。
|
||||
|
||||
重点对照设计:
|
||||
|
||||
- `ARCHITECTURE.md`
|
||||
- `design/01-05-current-priority-overview.md`
|
||||
- `design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
|
||||
- `design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`
|
||||
- `design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
|
||||
- `design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
|
||||
- `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
|
||||
- `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
|
||||
- `design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md`
|
||||
- `design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
|
||||
|
||||
重点代码:
|
||||
|
||||
- `rust/crates/core-protocol/`
|
||||
- `rust/crates/core-domain/`
|
||||
- `rust/crates/bridge-runtime/`
|
||||
- `rust/crates/mnote-web/`
|
||||
- `rust/crates/storage-convex-bridge/`
|
||||
|
||||
## 结论
|
||||
|
||||
Rust kernel / protocol / bridge-runtime / mnote-web 的主干方向与当前设计基本一致:`core-protocol` 已有统一 kernel node / edge / projection / page aggregate 协议;`bridge-runtime` 已能执行 kernel query / command / projection;`mnote-web` 已持有 3000 主入口、文档页 shell、`/api/page-aggregate/:id`、tree command、`/api/tree/events`、Search shell 等关键接缝;`storage-convex-bridge` 明确把 `tree.*` / `page.*` 等语义命令映射到底层 Convex mutation。
|
||||
|
||||
主要偏差不是“Rust 主线没有落地”,而是当前仍存在几类收口缺口:
|
||||
|
||||
1. 部分 Rust Web 主路径仍会静默合成 fallback / fixture 数据,和 `Runtime Fallback 退场` 的激进口径不完全一致。
|
||||
2. Page Aggregate 对外标记为 `KernelProjection`,但 Rust Web 仍先取 `documents.meta/content` 再在 runtime 内拼成聚合;这是合理过渡实现,但“单一 kernel 真源”口径容易被写得过满。
|
||||
3. Tree realtime 已有正式 SSE 主链,但实现仍偏轮询 bridge overview,WS 仍是 snapshot/resync 骨架,尚未成为完整实时主链。
|
||||
4. Legacy / compat 能力默认大多关闭,但配置位、proxy 辅助函数和兼容源枚举仍在,设计文档中“全部退场”的勾选状态可能偏乐观。
|
||||
|
||||
## 关键发现表格
|
||||
|
||||
| 编号 | 分类 | 严重度 | 发现 | 证据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| RK-01 | 未完成 | 中 | Kernel projection 仍主要从 `sidebar.dataset.list` 这一份 Convex 数据集构建,广义 node pool / reference edge / summary/index 真相层尚未完全独立。 | `rust/crates/mnote-web/src/routes/snapshot_support.rs:27` 定义 `sidebar.dataset.list`;`:93` 先加载 sidebar dataset 再执行 `kernel.project_view`;`rust/crates/bridge-runtime/src/lib.rs:8010` 后续由 sidebar 数据构建 subtree;`:8111` 再转为 projection。 |
|
||||
| RK-02 | 实现偏差 / 风险 | 高 | Rust Web 主路径仍存在静默 fallback:workspace shell 在 projection 加载失败时无条件合成最小 workspace/documents;sidebar/filetree 在 `allow_dev_fixtures` 时合成开发数据;Search 在 Convex query 失败时返回内置搜索数据。这与 `3-15-runtime-fallback-retirement` 的“默认不再 fallback”口径冲突。 | `rust/crates/mnote-web/src/routes/web_shell.rs:2379` 到 `:2401` 无条件 fallback 最小 dataset;`:2432` 到 `:2461` 合成 sidebar dev dataset;`:2493` 到 `:2519` 合成 filetree dev dataset;`rust/crates/mnote-web/src/routes/search.rs:233` 到 `:247` 搜索失败后走 `fallback_search_dataset`。 |
|
||||
| RK-03 | 方向变化 / 文档滞后 | 中 | Page Aggregate 已由 Rust Web 暴露正式 route,但实现仍是先读 `documents.meta/content`,再把 joined data 交给 `page.aggregate.get` 生成 projection。代码对外 source 标成 `KernelProjection`,但底层仍是 meta/content join 的迁移形态;需要文档明确这是 Rust runtime adapter,而非完整 kernel storage 真源。 | `rust/crates/mnote-web/src/routes/web_shell.rs:2282` 到 `:2315` 先读 meta/content 再执行 `page.aggregate.get`;`rust/crates/bridge-runtime/src/lib.rs:8246` 到 `:8253` 将 source 传为 `PageAggregateSource::KernelProjection`;`:5303` 到 `:5324` 从 meta/content 中抽取 title/content/revision。 |
|
||||
| RK-04 | 未完成 | 中 | Tree realtime 正式 route 已存在,但当前 SSE 仍通过 polling `bridge.workspace.overview` 生成 snapshot/delta/resync;WS 只发送初始 snapshot,并只支持客户端请求 resync,不是完整主实时链路。 | `rust/crates/mnote-web/src/routes/sse.rs:51` 到 `:91` 循环 sleep/poll overview 后生成 delta;`rust/crates/mnote-web/src/routes/ws.rs:32` 发送 snapshot,`:40` 到 `:80` 只处理 resync 或 unsupported ack。 |
|
||||
| RK-05 | 风险 / 文档滞后 | 低 | Legacy Next compat 默认关闭,但代码仍保留 env-gated proxy 能力、Next proxy 辅助函数和 compat 配置位。若文档继续写“legacy compat 已删除”,会与代码事实不一致;若保留,应写成显式调试/迁移边界。 | `rust/crates/mnote-web/src/app.rs:42` 到 `:54` 仍读取 `MNOTE_WEB_LEGACY_NEXT_BASE_URL` / `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT`;`rust/crates/mnote-web/src/routes/gateway.rs:86` 到 `:87` auth API 可转 legacy proxy;`:402` 到 `:499` legacy proxy 实现仍在;`rust/crates/mnote-web/src/routes/documents.rs:153` 当前 `should_proxy_via_next` 返回 false,但 Next proxy helper 仍保留。 |
|
||||
| RK-06 | 实现一致 | 低 | Tree command cutover Stage 2 的长期命名面已在 Rust route / bridge / storage mapping 中落地,`documents.*` 仍作为 alias/底层 Convex mutation 名称存在,和 Stage 2A/2B 过渡口径基本一致。 | `rust/crates/storage-convex-bridge/src/mapping.rs:37` 到 `:43` 映射 `tree.*`;`:52` 到 `:68` 保留 `documents.*` / `page.*` alias;`rust/crates/mnote-web/src/tree_shell/dispatcher.rs:16` 到 `:23` 使用 `tree.*`;`rust/crates/mnote-web/src/routes/tree.rs:7634` 附近测试确认 documents alias 映射仍存在。 |
|
||||
| RK-07 | 实现一致 | 低 | Debug shell 默认关闭,符合 `3104`/debug 壳默认退场口径。 | `rust/crates/mnote-web/src/routes/mod.rs:120` 到 `:124` 仅在 `enable_debug_shell_routes` 时挂 `/tree` 和 `/document-debug`;同文件测试 `:142` 到 `:184` 验证默认 404。 |
|
||||
|
||||
## 证据
|
||||
|
||||
### 1. Kernel / projection 已落地,但底层仍主要基于 sidebar dataset
|
||||
|
||||
- `rust/crates/core-protocol/src/kernel.rs:7` 到 `:42` 定义 `KernelNodeType`、`KernelEdgeType`、`KernelProjectionKind`。
|
||||
- `rust/crates/mnote-web/src/routes/kernel.rs:62` 到 `:85` 暴露 projection route,并经 `load_projection_snapshot` 获取投影。
|
||||
- `rust/crates/mnote-web/src/routes/snapshot_support.rs:27` 到 `:47` 固定 `sidebar.dataset.list` 为数据获取入口。
|
||||
- `rust/crates/bridge-runtime/src/lib.rs:8119` 到 `:8137` 在没有 root 时由当前数据构建 subtree,再生成 projection;`file_tree` 另走 `build_file_tree_projection_result`。
|
||||
|
||||
判断:这符合 `1-1` 中“真实主线已落地,但更广义 node pool/reference edge 仍后续”的口径,不应被写成“尚未开始”;也不应被写成“完整 kernel 真源已闭环”。
|
||||
|
||||
### 2. Runtime fallback 仍在主路径附近
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:2379` 到 `:2401`:workspace shell projection 加载失败时合成 `active_workspace_id`、`workspaces`、`documents`。
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:2432` 到 `:2461`:`allow_dev_fixtures` 时合成 sidebar dev dataset。
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:2493` 到 `:2519`:`allow_dev_fixtures` 时合成 filetree dev dataset。
|
||||
- `rust/crates/mnote-web/src/routes/search.rs:233` 到 `:247`:搜索 Convex 查询失败后执行 `fallback_search_dataset(workspace_id)`。
|
||||
|
||||
判断:这与 `design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md` 的“默认运行时不再 fallback / 不静默降级”存在偏差。尤其 Search fallback 会产生看似真实的固定结果,风险高于空状态降级。
|
||||
|
||||
### 3. Page Aggregate 是 Rust route,但仍是迁移期 adapter
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/mod.rs:53` 到 `:56` 挂载 `/api/page-aggregate/{document_id}`。
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:2264` 到 `:2319` 构建 aggregate。
|
||||
- `rust/crates/bridge-runtime/src/lib.rs:5291` 到 `:5442` 从 meta/content 形状构建 `PageAggregateProjection`。
|
||||
- `rust/crates/core-protocol/src/page_aggregate.rs:4` 到 `:10` 协议层仍保留 `KernelProjection`、`CompatMetaContentJoin`、`Fixture` 三种 source。
|
||||
- `rust/crates/mnote-web/src/page_aggregate/builder.rs:66` 默认 source 仍是 `CompatMetaContentJoin`,但当前检索未发现该 builder 进入主要 route 主路径。
|
||||
|
||||
判断:当前代码已摆脱 TS builder runtime 主链,但还不是“页面域全部直接来自独立 kernel storage 真源”。文档应保留“Rust-first Page Aggregate 过渡态”的精确口径。
|
||||
|
||||
### 4. Tree realtime 主链已成立,但不是完整实时闭环
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/mod.rs:113` 挂载 `/api/tree/events`,`:114` 挂载 `/api/stream/events`,`:115` 挂载 `/api/realtime/ws`。
|
||||
- `rust/crates/mnote-web/src/routes/sse.rs:127` 到 `:146` 给 `/api/tree/events` 打 `x-mnote-web-owner: mnote-web` 与 `x-mnote-tree-stream-owner: rust-web`。
|
||||
- `rust/crates/mnote-web/src/routes/sse.rs:51` 到 `:91` 通过 sleep/poll bridge overview 发现变化。
|
||||
- `rust/crates/mnote-web/src/routes/ws.rs:32` 到 `:80` WebSocket 当前只发 snapshot,并响应 resync 请求。
|
||||
|
||||
判断:这与 `3-3` 中“已完成 route / snapshot / delta / resync 基础,WS 尚未成为主链,live cache 未完全统一”一致;若 `3-15` 写成 tree realtime 补偿链已完全删除,则偏乐观。
|
||||
|
||||
### 5. Compat / legacy 未成为默认主链,但未完全删除
|
||||
|
||||
- `rust/crates/mnote-web/src/app.rs:42` 到 `:54` 仍读取 legacy Next 相关环境变量,默认 `enable_legacy_next_compat` 为 false。
|
||||
- `rust/crates/mnote-web/src/routes/gateway.rs:86` 到 `:87` 在 compat 开启且配置 legacy base URL 时,`/api/auth` 可走 legacy proxy。
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs:153` 当前 `should_proxy_via_next` 返回 false,说明文档 API 默认不走 Next proxy。
|
||||
|
||||
判断:代码实际状态更像“默认关闭、残留显式迁移/调试能力”,不是“所有 legacy proxy 代码已删除”。
|
||||
|
||||
## 建议优先级
|
||||
|
||||
### P0
|
||||
|
||||
- 移除或显式隔离 Search 的 `fallback_search_dataset`。失败时应返回明确错误或空 projection,并带可观测错误头;不要返回固定假结果。
|
||||
- 对 `load_workspace_shell_projection` 的无条件合成 dataset 做决策:若用于首屏容错,应在响应或 contract 中显式标记 degraded;若严格执行 fallback 退场,应改为失败或空树,不再伪装成真实 workspace projection。
|
||||
|
||||
### P1
|
||||
|
||||
- 将 `allow_dev_fixtures` 相关 fallback 全部加上更明确的 debug/dev 标识,并确认 `desktop:hot` / 3000 默认启动链不会误开。
|
||||
- 补一条 Rust Web smoke:Convex/query 不可用时,搜索、Sidebar、filetree 不应返回假业务数据。
|
||||
- Page Aggregate 文档补一句:当前 Rust route 已是主读链,但底层仍通过 Rust runtime adapter 消费 meta/content substrate;完整页面域单一真源仍在推进。
|
||||
|
||||
### P2
|
||||
|
||||
- Tree realtime 后续应把 SSE polling overview 与真正 domain event stream 的边界写清楚,并继续推进 page subtree / filetree / preferred snapshot 同一 live cache。
|
||||
- Legacy Next proxy 若仍需保留,建议统一命名为 explicit migration/debug boundary;若不再需要,后续单独删除 proxy helper、配置位和测试样例,避免和 `3-15` 的退场状态长期冲突。
|
||||
- `PageAggregateSource::CompatMetaContentJoin` / `Fixture` 是否继续保留在 `core-protocol` 需要架构决策:保留则标记为迁移态 source;删除则需先确认没有测试、local folder、fixture 依赖。
|
||||
|
||||
## 修改的文件路径
|
||||
|
||||
- `/mnt/Data1T/mnote/design/10-review/01-rust-kernel-web-review.md`
|
||||
@@ -0,0 +1,123 @@
|
||||
# 前端编辑器 / 树体验实现偏差审查
|
||||
|
||||
## 范围
|
||||
|
||||
本报告只审查 `wolai-frontend` 当前文档页、`leptos-tiptap` island host、`Page Aggregate` 消费链、Sidebar / tree shell / tree stream 与设计文档的一致性。
|
||||
|
||||
重点对照设计:
|
||||
|
||||
- `ARCHITECTURE.md`
|
||||
- `design/01-05-current-priority-overview.md`
|
||||
- `design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md`
|
||||
- `design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
|
||||
- `design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
|
||||
- `design/04-tree-domain/done/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md`
|
||||
- `design/04-tree-domain/process/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md`
|
||||
- `design/05-editor-mainline/process/5-4-leptos-tiptap-mainline-correction-v1.md`
|
||||
- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||
- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
|
||||
|
||||
重点读取代码:
|
||||
|
||||
- `wolai-frontend/src/app/(app)/documents/`
|
||||
- `wolai-frontend/src/components/editor/`
|
||||
- `wolai-frontend/src/lib/documents/`
|
||||
- `wolai-frontend/src/components/sidebar/`
|
||||
- `wolai-frontend/src/lib/tree-stream/`
|
||||
- `wolai-frontend/src/lib/documents/tree-command-client.ts`
|
||||
|
||||
## 结论
|
||||
|
||||
当前实现总体方向与最新设计主线一致:文档页 SSR 入口已消费 Rust `mnote.page_aggregate.v1`,默认主编辑器已折返到页面内 `leptos_tiptap_island`,tree command 前端主调用已进入 `/api/tree/commands` + `tree.*` 元数据口径,Sidebar 的默认 tree shell 已切到 `rust_wasm_dom_shell_host`,并且 `iframe_srcdoc` 只在显式 legacy flag 下存在。
|
||||
|
||||
但不能把当前状态描述为“前端编辑器 / 树体验已经完全收口到 Rust 单一真源”。主要未完成点集中在三处:页面本地 aggregate reducer 仍承担临时真相与补偿选择,`pageSubtree` 在本地正文变更后会被置空而不是形成同一份可持续 projection,Sidebar 仍通过 initial / query / tree stream 三源 freshness 选择维持一致性。此外,部分页面设置仍明确是 `planned` / `downgrade`,属于设计清单中尚未完成的范围。
|
||||
|
||||
## 关键发现表格
|
||||
|
||||
| 编号 | 分类 | 严重度 | 发现 | 证据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F-01 | 未完成 | 中 | Page Aggregate 读链已 Rust-first,但客户端仍有本地 aggregate reducer 持有标题、正文、设置、子树快照等临时真相;设计中的“页面域单一真源”尚未闭环。 | `wolai-frontend/src/components/editor/page-aggregate-client-state.ts:7` 定义本地 `PageAggregateClientState`,包含 `serverPageTitle / persistedPageTitle / draftPageTitle / options / content / serverPageSubtreeSnapshot / contentRevision / conflictDetectionKey`;`wolai-frontend/src/components/editor/document-content.tsx:144` 使用 reducer 作为文档页核心状态。 |
|
||||
| F-02 | 实现偏差 | 中 | `pageSubtree` 不是跟随本地正文和标题持续更新的 page projection;一旦本地正文对象与服务器快照不同,就直接返回 `null`,AI 面板和阅读结构面会失去这份 projection。 | `wolai-frontend/src/components/editor/page-aggregate-client-state.ts:163` 通过 `content === serverContentSnapshot` 判断;`wolai-frontend/src/components/editor/page-aggregate-client-state.ts:166` 只有未改动时返回 `serverPageSubtreeSnapshot`,否则 `:169` 返回 `null`;`wolai-frontend/src/components/editor/document-content.tsx:867` 将该选择结果作为 AI snapshot 的 `pageSubtree` 来源,`:1162` 也传给阅读视图。 |
|
||||
| F-03 | 未完成 | 中 | Sidebar / Breadcrumb / 文档页头已共享 preferred snapshot,但 live cache 仍不是唯一来源;当前仍在 initial、query refetch、tree stream 之间做 freshness 选择。 | `wolai-frontend/src/components/app-layout-shell.tsx:20` 同时创建 `useSidebarData` 与 `useSidebarTreeStream`;`:22` 用 `usePreferredSidebarSnapshot` 选择;`wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts:29` 在 `query / tree_stream / initial` 间选择;`wolai-frontend/src/components/sidebar/sidebar.tsx:201` Sidebar 内部也消费同样选择结果。 |
|
||||
| F-04 | 未完成 | 低 | 页面设置运行时语义已有代码级分类,但仍有正式 UI 中可见的 planned / downgrade 项;这与 `5-6` 清单中“页面设置未整体收口”的口径一致。 | `wolai-frontend/src/lib/documents/page-option-semantics.ts:60` 将 `protectEditing` 标记为 `planned`,`:84` 将 `showBlockRefCount` 标记为 `planned`;`wolai-frontend/src/components/editor/page-options-sidebar.tsx:416` 对 downgrade 项显示 `待接线`,`:71` 让 `showBlockRefCount` 成为不可交互占位。 |
|
||||
| F-05 | 方向变化/文档滞后 | 低 | `5-5-1` 中仍写着 `showHeadingNumbers / embedDefaultBlockId` 只完成字段贯通,但当前代码已把它们纳入 island runtime payload;这里更像文档滞后,而不是实现偏差。 | `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` 第 2.3 节仍说明两项“不可描述成正式支持”;`wolai-frontend/src/lib/documents/page-option-semantics.ts:48` 与 `:88` 均标记为 `wired`;`:121` 的 `pickLeptosTiptapRuntimePageOptions` 会把 `showHeadingNumbers / embedDefaultBlockId` 传给 island,`wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx:717` 运行时更新这些选项。 |
|
||||
| F-06 | 风险 | 低 | Tree final DOM shell 默认路径已符合 `4-18`,但 legacy iframe host 仍可被环境变量显式打开;后续需要保持负向 smoke,防止默认路径回退。 | `wolai-frontend/src/components/sidebar/tree-shell-host.tsx:131` 默认 `rust_family` 且有 workspace 时使用 DOM host,`:133` 仅在 `NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST=1` 时启用 legacy iframe;`wolai-frontend/src/components/sidebar/tree-shell-host.test.tsx:182` 覆盖默认不能进入 `iframe_srcdoc`,`:199` 覆盖显式 legacy flag。 |
|
||||
|
||||
## 证据
|
||||
|
||||
### 已对齐部分
|
||||
|
||||
1. 文档页读取主链已进入 Rust Page Aggregate。
|
||||
- `wolai-frontend/src/app/(app)/documents/[id]/page.tsx:51` 调用 `loadPageAggregateFromNextHeaders`。
|
||||
- `wolai-frontend/src/lib/documents/page-aggregate-loader.ts:170` 请求 `/api/page-aggregate/:id`。
|
||||
- `wolai-frontend/src/lib/documents/page-aggregate-loader.ts:130` 校验 schema 必须是 `mnote.page_aggregate.v1`。
|
||||
- `wolai-frontend/src/lib/documents/page-aggregate-loader.ts:225` 的 `loadPageAggregate` 只返回 Rust snapshot,不再在运行时 fallback 到 TS builder。
|
||||
|
||||
2. 默认编辑器 host 已是页面内 `leptos_tiptap_island`。
|
||||
- `wolai-frontend/src/components/editor/editor-host-config.ts:1` 只保留 `EditorHostKind = "leptos_tiptap_island"`。
|
||||
- `wolai-frontend/src/components/editor/editor-host.tsx:7` 动态加载 `leptos-tiptap-island-editor-host`。
|
||||
- `wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx:619` 直接调用 runtime module 的 `mount(container, ...)`,不是 iframe。
|
||||
|
||||
3. 页面写命令已经开始按 page command family 收口。
|
||||
- `wolai-frontend/src/lib/documents/page-command-contract.ts:15` 固定 `page.head.updateTitle / page.layout.updateOptions / page.body.save`。
|
||||
- `wolai-frontend/src/lib/documents/page-command-client.ts:72` 标题写入发送 `commandName: page.head.updateTitle`。
|
||||
- `wolai-frontend/src/lib/documents/page-command-client.ts:89` 页面设置写入发送 `commandName: page.layout.updateOptions`。
|
||||
- `wolai-frontend/src/app/api/documents/save/route.ts:35` 服务端 envelope 使用 `PAGE_COMMAND_NAMES.saveBody`。
|
||||
|
||||
4. tree command 前端调用主面已以 `tree.*` 为结果元数据口径。
|
||||
- `wolai-frontend/src/lib/documents/tree-command-client.ts:26` 定义 `tree.node.create / tree.node.rename / tree.subtree.move / tree.node.archive` 等 preferred command。
|
||||
- `wolai-frontend/src/lib/documents/tree-command-client.ts:185` 统一 POST 到 `/api/tree/commands`。
|
||||
- `wolai-frontend/src/app/api/tree/commands/route.ts:205` 由 `action` 分发 tree command,`:278` 创建命令使用 `tree.node.create`。
|
||||
|
||||
5. tree final DOM shell 默认路径已符合硬门禁。
|
||||
- `wolai-frontend/src/components/sidebar/tree-shell-host.tsx:139` 默认 implementation 为 `rust_wasm_dom_shell_host`。
|
||||
- `wolai-frontend/src/components/sidebar/tree-shell-surface.tsx:113` 旧 React page tree renderer 已显示为 removed fallback,不再作为正常 renderer。
|
||||
- `wolai-frontend/src/components/sidebar/tree-shell-host.test.tsx:189` 测试断言默认 implementation 是 `rust_wasm_dom_shell_host`,`:194` 断言没有 iframe host。
|
||||
|
||||
### 仍需收口部分
|
||||
|
||||
1. 客户端 `PageAggregateClientState` 仍是混合态。
|
||||
- 它把 server snapshot、persisted title、draft title、本地 content、server subtree snapshot、revision/conflict key 放在同一个前端 reducer 中。
|
||||
- 这符合当前过渡态,但不等于 Rust page aggregate 已成为页面域唯一运行时真相。
|
||||
|
||||
2. `pageSubtree` 与正文编辑没有同源更新。
|
||||
- 本地正文变更只会触发 `apply_local_content_snapshot`,不会同步生成新的 `pageSubtree`。
|
||||
- selector 在内容对象不同于 server snapshot 时返回 `null`,因此 `AI / read view / TOC` 只能等待后续持久化与重新拉取。
|
||||
|
||||
3. Sidebar live cache 仍有 freshness 选择层。
|
||||
- `AppLayoutShell` 和 `Sidebar` 都依赖 `usePreferredSidebarSnapshot`。
|
||||
- 当前算法没有基于 Rust stream cursor / projection version 做统一仲裁,而是通过 sync key 与 `treeStreamStatus` 在 `query` 与 `tree_stream` 之间选择。
|
||||
|
||||
4. 页面设置中仍有显式未完成项。
|
||||
- `protectEditing` 与 `showBlockRefCount` 的代码状态已经诚实标为 `planned / downgrade`。
|
||||
- 这避免了误导用户,但也说明 `Page Aggregate -> island runtime` 的设置语义还没有完全完成。
|
||||
|
||||
## 建议优先级
|
||||
|
||||
### P0
|
||||
|
||||
暂无需要立即阻断主链的 P0。默认 Page Aggregate 读链、默认 island host、默认 DOM tree shell 均未发现与最新主线相反的实现。
|
||||
|
||||
### P1
|
||||
|
||||
1. 收口 `pageSubtree` 与本地正文编辑的关系。
|
||||
- 至少明确它是“server projection only”还是“本地编辑也应生成临时 projection”。
|
||||
- 如果 AI 面板需要稳定结构上下文,不应在用户正常编辑后直接拿到 `null`。
|
||||
|
||||
2. 将 Sidebar 的三源 preferred snapshot 推进为统一 live cache。
|
||||
- 建议以 Rust stream cursor / projection version / query snapshot version 为仲裁字段,减少仅靠 sync key 的 freshness 判断。
|
||||
|
||||
### P2
|
||||
|
||||
1. 继续移除或强门禁 legacy tree iframe host。
|
||||
- 当前显式 env flag 符合设计,但应保留负向 smoke,避免后续默认路径误回退。
|
||||
|
||||
2. 同步修正文档滞后。
|
||||
- `5-5-1` 对 `showHeadingNumbers / embedDefaultBlockId` 的描述已经落后于当前代码,应在后续文档整理时更新。
|
||||
|
||||
3. 页面设置 planned 项保持降级展示,直到真正进入 island runtime 或被产品侧移除。
|
||||
|
||||
## 修改的文件
|
||||
|
||||
- `/mnt/Data1T/mnote/design/10-review/02-frontend-editor-tree-review.md`
|
||||
@@ -0,0 +1,215 @@
|
||||
# Convex / Realtime / Storage 实现偏差审查
|
||||
|
||||
## 范围
|
||||
|
||||
本报告审查 Convex / storage bridge / realtime / Page Aggregate 数据底座与当前设计主线的一致性,重点对照以下方向:
|
||||
|
||||
- Convex 保留为自托管 storage / realtime substrate,不作为树语义 owner。
|
||||
- Rust kernel / bridge-runtime / mnote-web 持有 tree-first graph、projection、command、Page Aggregate 的语义主导权。
|
||||
- `/api/page-aggregate/:id` 作为文档页 Rust-first 读取主链。
|
||||
- `/api/tree/events` 作为 Rust Web tree realtime snapshot / delta / resync 主链。
|
||||
- 前端只消费稳定 projection、Page Aggregate 与 tree stream,不重新持有第二套对象真相。
|
||||
|
||||
重点读取范围:
|
||||
|
||||
- `ARCHITECTURE.md`
|
||||
- `design/01-05-current-priority-overview.md`
|
||||
- `design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
|
||||
- `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
|
||||
- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||
- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
|
||||
- `infra/convex/README.md`
|
||||
- `wolai-frontend/convex/`
|
||||
- `rust/crates/storage-convex-bridge/`
|
||||
- `rust/crates/mnote-web/src/transport/convex.rs`
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs`
|
||||
- `rust/crates/mnote-web/src/routes/tree.rs`
|
||||
- `rust/crates/mnote-web/src/routes/sse.rs`
|
||||
- `rust/crates/mnote-web/src/routes/stream_support.rs`
|
||||
- `wolai-frontend/src/lib/documents/`
|
||||
- `wolai-frontend/src/lib/tree-stream/`
|
||||
|
||||
## 结论
|
||||
|
||||
整体方向与设计主线基本一致:Convex 没有被拆掉,仍承担本地自托管存储、文件和实时底座;Rust Web 已注册 `/api/page-aggregate/:document_id` 与 `/api/tree/events`;前端 tree stream 已直接使用 `EventSource("/api/tree/events")`;Next 的 `/api/documents/page` 与 `/api/mnote-web/stream` 已明确作为 410 compat 边界退场。
|
||||
|
||||
但当前实现仍是明显过渡态,主要问题集中在三处:
|
||||
|
||||
1. Page Aggregate 读链虽然是 Rust endpoint,但实际数据仍由 `documents:getMeta` + `documents:getContent` 分别取回后在 bridge-runtime 中拼 projection,`source` 标识存在“看起来比真实实现更 canonical”的风险。
|
||||
2. Rust Web 的 `/api/documents/title`、`/api/documents/options` 与 Next route 的 page write adapter 在 bridge artifact 记录上不一致;tree stream 依赖 bridgeLogs 时,Rust page write 路径可能漏掉 command/domain event。
|
||||
3. `/api/tree/events` 是正式 SSE 入口,但当前 change detection 依赖周期性查询 `bridgeLogs:listWorkspaceOverview`,且 Convex query 内部会按 workspace collect 全量 command/domain rows 后内存过滤排序,实时性和规模风险较高。
|
||||
|
||||
## 关键发现表格
|
||||
|
||||
| 编号 | 分类 | 严重度 | 发现 | 证据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F1 | 实现偏差 | 高 | Rust Page Aggregate endpoint 对外是 `mnote.page_aggregate.v1`,但构建过程仍先分别读取 meta/content,再在 bridge-runtime 中拼 projection;`source=KernelProjection` 容易掩盖底层仍是兼容 join。 | `rust/crates/mnote-web/src/routes/web_shell.rs:2282`、`:2291`、`:2301`;`rust/crates/bridge-runtime/src/lib.rs:8246`、`:8252`、`:5291`、`:5303`、`:5386` |
|
||||
| F2 | 风险 | 高 | Rust Web 的 title/options 写路由只执行 Convex mutation,不记录 bridge command/domain artifacts;而 tree stream 以 bridgeLogs 为事件来源,主路径 page 写入可能不进入实时流。 | `rust/crates/mnote-web/src/routes/documents.rs:714`、`:802`;`rust/crates/mnote-web/src/routes/command_support.rs:65`;对比 `rust/crates/mnote-web/src/routes/tree.rs:6529` 与 `wolai-frontend/src/lib/documents/page-write-command-adapter.ts:63` |
|
||||
| F3 | 风险 | 高 | tree realtime 当前是 SSE transport,但后端通过 `sleep(poll_ms)` 周期性轮询 Convex bridgeLogs,而不是直接利用 Convex subscription/push;默认 2s,实时性和负载都需继续验证。 | `rust/crates/mnote-web/src/routes/sse.rs:25`、`:26`、`:51`、`:58`、`:60`;`rust/crates/mnote-web/src/routes/stream_support.rs:610` |
|
||||
| F4 | 风险 | 中高 | `bridgeLogs:listWorkspaceOverview` 会按 workspace collect 全量 `command_logs` 与 `domain_events` 后在内存中过滤、排序、分页;SSE 轮询叠加后,历史日志增长会放大 Convex 读压力。 | `wolai-frontend/convex/bridgeLogs.ts:283`、`:287`、`:292`、`:301`、`:305` |
|
||||
| F5 | 方向变化/文档滞后 | 中 | `storage-convex-bridge` 把 `page.aggregate.get` 映射到 `documents:getPageAggregate`,但 Convex `documents.ts` 只发现 `getMeta` / `getContent`,未发现 `getPageAggregate` export;当前真实 Page Aggregate 路线已绕到 Rust adapter。 | `rust/crates/storage-convex-bridge/src/mapping.rs:101`;`wolai-frontend/convex/documents.ts:684`、`:803`;全仓 `rg getPageAggregate` 未发现 Convex 实现 |
|
||||
| F6 | 已完成/方向一致 | 中 | Next compat 读链和旧 stream alias 已显式退场,前端 tree stream 直接构造 `/api/tree/events`,符合当前主线口径。 | `wolai-frontend/src/app/api/documents/page/route.ts:17`;`wolai-frontend/src/app/api/mnote-web/stream/route.ts:9`;`wolai-frontend/src/lib/tree-stream/protocol.ts:66`、`:72` |
|
||||
| F7 | 未完成 | 中 | Page Aggregate TS builder 已不在 runtime 主链中被引用,但文件仍保留;当前定位应继续写成 fallback / adapter / 测试材料,不能作为运行时主路径描述。 | `wolai-frontend/src/lib/documents/page-aggregate-builder.ts:51`;全仓非测试引用仅剩定义,runtime loader 只调用 Rust snapshot:`wolai-frontend/src/lib/documents/page-aggregate-loader.ts:225` |
|
||||
|
||||
## 证据
|
||||
|
||||
### 1. Page Aggregate 读链已收口到 Rust endpoint,但仍由 meta/content join 生成
|
||||
|
||||
`mnote-web` 注册了正式 endpoint:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/mod.rs:53` 到 `:56` 注册 `/api/page-aggregate/{document_id}`。
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:2227` 到 `:2252` 返回 `schema: "mnote.page_aggregate.v1"` 和 `result: aggregate`。
|
||||
|
||||
但实际构建流程仍是:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:2282` 到 `:2290` 读取 `load_document_meta_result`。
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:2291` 到 `:2299` 读取 `load_document_content_result`。
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:2301` 到 `:2315` 把 `meta + content` 作为数据传给 `page.aggregate.get`。
|
||||
- `rust/crates/bridge-runtime/src/lib.rs:5291` 到 `:5312` 的 `build_page_aggregate_projection_result` 明确从 `data.meta` 和 `data.content` 中拆字段。
|
||||
|
||||
这说明“Rust-first 读取主链”成立,但“Page Aggregate 已经由 kernel 原生投影独立产出”仍未成立。该点与 `5-5/5-6` 的过渡态判断一致,但需要在后续文档和汇报中保持精确。
|
||||
|
||||
需进一步验证:`PageAggregateSource::KernelProjection` 是否应在这种 `meta/content join` 场景下继续使用,还是应保留 `CompatMetaContentJoin` 以避免 provenance 误导。
|
||||
|
||||
### 2. Page write side effects 在 Rust route 与 Next adapter 之间不一致
|
||||
|
||||
Next route 侧:
|
||||
|
||||
- `wolai-frontend/src/app/api/documents/title/route.ts:58` 调用 `executePageWriteBridgeCommand`。
|
||||
- `wolai-frontend/src/app/api/documents/options/route.ts:47` 调用 `executePageWriteBridgeCommand`。
|
||||
- `wolai-frontend/src/app/api/documents/save/route.ts:43` 调用 `executePageWriteBridgeCommand`。
|
||||
- `wolai-frontend/src/lib/documents/page-write-command-adapter.ts:63` 到 `:69` 成功后调用 `recordRustBridgeCommandArtifacts`。
|
||||
|
||||
Rust route 侧:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs:579` 的正文保存使用 `execute_runtime_command_via_convex_with_artifacts`。
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs:714` 的标题更新使用 `execute_runtime_command_via_convex`。
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs:802` 的页面设置更新使用 `execute_runtime_command_via_convex`。
|
||||
- `rust/crates/mnote-web/src/routes/command_support.rs:65` 到 `:73` 的 `execute_runtime_command_via_convex` 只执行 Convex command plan,不持久化 artifacts。
|
||||
- `rust/crates/mnote-web/src/routes/command_support.rs:75` 到 `:93` 的 artifact 版本才会调用 `execute_convex_command_plan_with_artifacts`。
|
||||
|
||||
tree command route 是一致的:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/tree.rs:6529` 使用 `execute_runtime_command_via_convex_with_artifacts`。
|
||||
|
||||
风险是:如果当前 3000 主入口走 Rust Web `/api/documents/title` 或 `/api/documents/options`,这些 page 写入不会进入 `command_logs/domain_events`,而 `/api/tree/events` 正是通过 bridgeLogs 检测变化。标题类写入尤其可能影响 sidebar/breadcrumb/page tree 的实时一致性。
|
||||
|
||||
需进一步验证:当前文档页标题编辑在 3000 主壳中到底走 `/api/documents/title` 还是 `/api/tree/commands`;如果走前者,应补 Rust route artifact 记录或统一到 tree/page command route。
|
||||
|
||||
### 3. Tree realtime 是正式 SSE 入口,但实现仍是 polling-backed stream
|
||||
|
||||
正式入口成立:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/mod.rs:130` 注册 `/api/tree/events`。
|
||||
- `rust/crates/mnote-web/src/routes/sse.rs:127` 到 `:146` 给 tree events 加 `x-mnote-tree-stream-owner: rust-web`。
|
||||
- `wolai-frontend/src/lib/tree-stream/protocol.ts:66` 到 `:77` 构造 `/api/tree/events` URL。
|
||||
- `wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts:136` 到 `:143` 用 `EventSource` 监听 `snapshot/delta/resync`。
|
||||
|
||||
但服务端 change detection 是轮询:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/sse.rs:25` 到 `:26` 读取 `max_polls` 和 `poll_ms`,默认 `2000ms`,最低 `250ms`。
|
||||
- `rust/crates/mnote-web/src/routes/sse.rs:51` 到 `:58` 循环中 `sleep(Duration::from_millis(poll_ms))`。
|
||||
- `rust/crates/mnote-web/src/routes/sse.rs:60` 到 `:67` 每轮调用 `load_stream_overview` 再 `resolve_stream_change`。
|
||||
- `rust/crates/mnote-web/src/routes/stream_support.rs:610` 到 `:625` 的 `load_stream_overview` 通过 `bridge.workspace.overview` 查询 Convex。
|
||||
|
||||
这与“Rust Web 提供正式实时 transport”一致,但还不是“基于 Convex realtime subscription 的 push stream”。短期可接受为过渡实现,长期如果继续承载主链,需要明确性能和延迟边界。
|
||||
|
||||
### 4. bridgeLogs overview 查询存在规模风险
|
||||
|
||||
`wolai-frontend/convex/bridgeLogs.ts` 的 `listWorkspaceOverview`:
|
||||
|
||||
- `:283` 到 `:286` 按 workspace 查询并 `collect()` 所有 `command_logs`。
|
||||
- `:287` 到 `:290` 按 workspace 查询并 `collect()` 所有 `domain_events`。
|
||||
- `:292` 到 `:301` 在内存中过滤、排序、切片 command logs。
|
||||
- `:303` 到 `:312` 再用 commandId 集合过滤 domain events。
|
||||
|
||||
该实现作为调试/小数据过渡可以工作,但被 `/api/tree/events` 默认每 2 秒调用时,日志增长后会造成:
|
||||
|
||||
- Convex query 读放大。
|
||||
- SSE 连接数量增加时成倍放大。
|
||||
- cursor 语义依赖内存排序,历史数据规模变大后容易出现延迟或超时。
|
||||
|
||||
建议后续至少增加按 `workspace_id + created_at/id` 的索引分页,避免每次 stream poll 全量扫描。
|
||||
|
||||
### 5. storage-convex-bridge 中存在疑似过时的 `page.aggregate.get` 映射
|
||||
|
||||
`rust/crates/storage-convex-bridge/src/mapping.rs:101`:
|
||||
|
||||
```text
|
||||
"page.aggregate.get" => "documents:getPageAggregate"
|
||||
```
|
||||
|
||||
但当前 `wolai-frontend/convex/documents.ts` 中只发现:
|
||||
|
||||
- `getMeta`:`wolai-frontend/convex/documents.ts:684`
|
||||
- `getContent`:`wolai-frontend/convex/documents.ts:803`
|
||||
|
||||
全仓搜索未发现 Convex `documents:getPageAggregate` 实现。当前真实 Page Aggregate 读取是 Rust Web 先读 meta/content,再由 bridge-runtime 生成 projection。因此该映射要么是未来目标,要么已经滞后;如果有代码路径直接通过 storage-convex-bridge 执行 `page.aggregate.get`,会存在运行时函数不存在风险。
|
||||
|
||||
需进一步验证:`storage-convex-bridge` 是否仍有生产路径直接执行 `page.aggregate.get -> documents:getPageAggregate`;如果没有,应把映射标注为 future/stale,或改为当前真实读链。
|
||||
|
||||
### 6. Convex substrate 定位总体一致
|
||||
|
||||
正向证据:
|
||||
|
||||
- `infra/convex/README.md` 明确自托管 Convex backend/dashboard、HTTP Actions 和文件存储。
|
||||
- `wolai-frontend/convex/schema.ts:35` 到 `:91` 的 `documents` 表继续承接页面结构、正文、页面设置、统计字段。
|
||||
- `wolai-frontend/convex/schema.ts:121` 到 `:162` 的 `media_assets` 与 `_storage` 字段保留文件底座。
|
||||
- `rust/crates/storage-convex-bridge/README.md:5` 到 `:13` 明确 bridge 只做协议到 Convex 读写请求映射,不复制主事实层、不维护第二数据库。
|
||||
|
||||
这与“Convex 不拆,Rust 收口语义”的设计一致。
|
||||
|
||||
## 建议优先级
|
||||
|
||||
### P0:修正 Rust page write route 的 artifact 一致性
|
||||
|
||||
目标:让当前 3000 主入口的 `/api/documents/title`、`/api/documents/options` 至少在 side effect 上与 Next `page-write-command-adapter` 保持一致。
|
||||
|
||||
建议:
|
||||
|
||||
- 将 `rust/crates/mnote-web/src/routes/documents.rs` 中 title/options 的 `execute_runtime_command_via_convex` 改为 artifact 版本,或统一复用 tree/page command route。
|
||||
- 对标题更新补最小回归:更新标题后 `bridgeLogs:listWorkspaceOverview` 能看到对应 command/domain event,`/api/tree/events` 能输出 delta 或 resync。
|
||||
- 对 artifact 写失败的策略重新定级:当前 `rust/crates/mnote-web/src/transport/convex.rs:759` 到 `:770` 是主 mutation 成功、artifact 失败仍返回成功;对 tree-relevant command 至少应打可观测错误并触发保守 resync。
|
||||
|
||||
### P1:收口 Page Aggregate provenance
|
||||
|
||||
目标:避免把 `meta/content join` 误标为已完成的 kernel-native projection。
|
||||
|
||||
建议:
|
||||
|
||||
- 明确 `PageAggregateSource::KernelProjection` 与 `CompatMetaContentJoin` 的使用边界。
|
||||
- 如果 `build_page_aggregate_snapshot` 仍通过 `load_document_meta_result + load_document_content_result` 构建,应在返回 source 或 header 中反映真实来源。
|
||||
- 如果目标是 kernel-native projection,则补正式 kernel/query 数据路径,避免 route 层长期手工拼装。
|
||||
|
||||
### P1:优化 tree stream 的 Convex 查询模型
|
||||
|
||||
目标:让 `/api/tree/events` 可以长期承载主链,而不是随着日志增长退化。
|
||||
|
||||
建议:
|
||||
|
||||
- 为 `command_logs`、`domain_events` 增加按 `workspace_id + created_at/id` 的查询索引和 cursor 查询。
|
||||
- `listWorkspaceOverview` 不再 `collect()` 全量 workspace 日志后内存分页。
|
||||
- 明确 polling-backed SSE 是过渡实现,还是长期 realtime transport;若长期使用,应补连接数、日志量、延迟上限的 smoke/bench。
|
||||
|
||||
### P2:清理或标注 stale mapping
|
||||
|
||||
目标:降低后续 worker 误用 `page.aggregate.get -> documents:getPageAggregate` 的风险。
|
||||
|
||||
建议:
|
||||
|
||||
- 若 `documents:getPageAggregate` 不计划实现,移除或注释 `storage-convex-bridge` 中的映射。
|
||||
- 若计划实现,补 Convex query 与最小测试,并让 mnote-web Page Aggregate route 直接消费它或说明为什么不消费。
|
||||
|
||||
### P2:保留 Next compat 退场边界,但避免双实现继续发散
|
||||
|
||||
目标:Next route 继续作为 legacy/adapter 参考时,不与 Rust Web 主入口形成不同 side effects。
|
||||
|
||||
建议:
|
||||
|
||||
- 对 `/api/documents/title/options/save` 明确 owner:Rust Web 主路径与 Next legacy 路径只能有一份 canonical side-effect 规则。
|
||||
- 对 `wolai-frontend/src/lib/documents/page-aggregate-builder.ts` 保留测试/adapter 标签,避免被重新接回 runtime 主链。
|
||||
|
||||
## 修改的文件路径
|
||||
|
||||
- `design/10-review/03-convex-realtime-storage-review.md`
|
||||
@@ -0,0 +1,64 @@
|
||||
# 次级域与设计治理实现偏差审查
|
||||
|
||||
## 范围
|
||||
|
||||
本次只审查 Mindmap、AI、OnlyOffice、Wolai-aline、SiYuan reference,以及 `design/process/done` 治理口径与当前实现方向的一致性。重点读取了 `ARCHITECTURE.md`、`design/README.md`、`design/06-mindmap/process/*`、`design/07-ai/process/*`、`design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md`、`design/09-siyuan-reference/process/9-siyuan-reference-boundary-and-adoption-v1.md`、`design/90-reference/*`,并对照了 Mindmap、AI route/lib、OnlyOffice route/adapter 与相关 Convex/Rust runtime 代码。
|
||||
|
||||
## 结论
|
||||
|
||||
总体方向与当前主线基本一致:Mindmap 已明确降为 `tree-first graph kernel` 的视图/编辑挂件,AI 主执行面正在向 `mnote-cli` 收口,OnlyOffice 仍是独立页面型编辑器边界,SiYuan 参考稿没有发现被提升为上位架构来源的证据。
|
||||
|
||||
主要风险集中在三处:OnlyOffice 在 `mnote-web` 主入口里的 callback/forcesave 仍是 no-op,而 legacy Next route 已有真实写回链;Mindmap 的导出 action 在 schema/action map 中暴露,但 bridge 安全命令集不支持;Mindmap AI 补完 route 当前硬返回 501,和 Rust tool 已登记的能力面不闭合。
|
||||
|
||||
## 关键发现表格
|
||||
|
||||
| ID | 分类 | 级别 | 发现 | 简短证据 | 建议 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| F-01 | 实现偏差 / 风险 | P1 | OnlyOffice `mnote-web` 主入口已挂载 callback/forcesave,但当前实现不写回,只返回成功或 noop,可能让 3000 主链下的 OnlyOffice 保存丢失。 | `rust/crates/mnote-web/src/routes/mod.rs:78-84` 挂载 `/api/onlyoffice/callback` 与 `/api/onlyoffice/forcesave`;`rust/crates/mnote-web/src/routes/onlyoffice.rs:717-759` callback 只记录日志并返回 `{error:0}`,forcesave 返回 `mnote-web-rust-noop`;而 `wolai-frontend/src/app/api/onlyoffice/callback/route.ts:93-192` 有真实 Convex 写回链。 | 优先把 Rust route 接到 `onlyoffice_prepare_callback` / media asset writeback,或显式把该路由代理到 legacy Next,避免主入口 shadow 掉真实写回。 |
|
||||
| F-02 | 未完成 | P2 | Mindmap `export` 动作暴露给 UI action map,但 simple-mind-map 安全执行器不允许 `EXPORT`,默认路径可能显示能力却执行失败。 | `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts:65` 将 `export` 映射为 runtimeCommand `EXPORT`;`wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts:119-129` 的 `SIMPLE_MIND_MAP_SAFE_COMMANDS` 不包含 `EXPORT`。 | 要么把 `EXPORT` 加入安全命令并补 smoke,要么在 UI state 中继续禁用导出并标注为延期。 |
|
||||
| F-03 | 未完成 / 方向变化 | P2 | Mindmap AI 补完 route 当前直接 501,后续大段旧 Supabase/在线 AI 实现被注释;但 Rust tool registry 已登记 `mindmap_expand_node`,形成“工具存在、产品入口不可用”的断层。 | `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts:90-97` 校验后直接返回 `501`;同文件后续注释块仍保留旧 Supabase/AI 逻辑;`rust/crates/core-protocol/src/tool.rs:231-240` 与 `rust/crates/bridge-runtime/src/lib.rs:3803-3812` 已有 `mindmap_expand_node`。 | 若该能力仍在 Phase 6/7 范围内,应按 CLI/Rust bridge 路线重接;若延期,应把 route 标成 retired/debug,避免前端或测试误以为可用。 |
|
||||
| F-04 | 方向变化 / 文档滞后 | P2 | AI 主 Web route 已收口到 `mnote-cli` host,但 `wolai-backend` 仍暴露 `openai_agents_python` 文档 agent route 与旧工具面;是否仍部署为可访问入口需进一步验证。 | `wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 明确拒绝 codex/hermes/claudecode 并进入 `startMnoteCliAgentHostRun`;`wolai-backend/app/routers/ai_agent.py:34-57` 仍暴露 `/ai-agent/health`、`/ai-agent/document/run`,health 返回 `bridge: openai_agents_python`;`wolai-backend/app/services/ai_document_agent.py:1174-1470` 仍指令 agent 使用 `doc_insert_blocks`、`doc_replace_range`、`slash_run`。 | 在设计或代码注释中明确该后端只作为可插拔外置 agent/对照链;若不再使用,补退场计划和访问边界,尤其确认 `mnote_ai_orchestrator_api_key` 未配置时的开放行为。 |
|
||||
| F-05 | 文档治理风险 | P3 | `design/90-reference` 符合“参考资料”目录定位,但内容仍带有问答式残留,容易被后续 worker 误用为正式设计结论。 | `design/README.md` 明确 `90-reference/` 不参与 process/done 状态判断;`design/90-reference/90-1-filetree.md` 末尾保留“需要我给你...”类对话尾巴;`design/90-reference/90-2-yemianshu.md` 同样保留示例请求口吻。 | 低优先级清理为中性参考笔记,并在引用时强制以 `ARCHITECTURE.md` 和主线设计为上位依据。 |
|
||||
|
||||
## 证据
|
||||
|
||||
### Mindmap
|
||||
|
||||
- 设计口径清晰:`design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` 明确 Phase 6 主线是 `Rust kernel truth -> mindmap.simple_mind_map_scene.v1 -> leptos-mindmap editor island -> simple-mind-map runtime -> command bridge`,并禁止把 runtime data 当唯一事实源。
|
||||
- 实现已对齐主线壳:`rust/crates/mnote-web/src/routes/mindmap_shell.rs:105-123` 输出 `mnote.mindmap_shell.v1`、`mindmap.simple_mind_map_scene.get` 与 `mindmap.command.apply`;`rust/crates/mnote-web/src/ssr/pages/mindmap.rs:50-56` 提供 standalone island 挂载点。
|
||||
- 旧 React 块已自我标注为 legacy/compat/reference:`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx:3-5` 明确 3000 文档页默认主链是 `leptos-tiptap NodeView + leptos-mindmap adapter`。
|
||||
- 仍有 compat 数据层:`wolai-frontend/convex/schema.ts:164-184` 的 `mindmaps.data: v.any()` 仍承载导图数据;这与当前过渡态兼容,但不应被描述为长期 canonical truth。
|
||||
|
||||
### AI
|
||||
|
||||
- CLI-first 入口基本对齐:`wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 将默认执行入口限定到 `mnote-cli host`,拒绝旧 provider。
|
||||
- 结构化 artifact 设计仍有未完成项:`design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 中“后续权限收口”仍未勾选,说明 AI 写链权限模型尚未完全闭环。
|
||||
- 旧后端 agent 仍存在:`wolai-backend/app/routers/ai_agent.py:47-57` 仍提供 streaming run;这可作为外置 agent,但需要避免被误认为长期默认主编排。
|
||||
|
||||
### OnlyOffice
|
||||
|
||||
- 正确边界已有:`rust/crates/adapter-onlyoffice/src/lib.rs:124-126` 明确 adapter 只定义资产定位、会话、签名、代理、callback/forcesave 边界,不把 OnlyOffice 变成主事实层。
|
||||
- `mnote-web` 页面是独立编辑页:`rust/crates/mnote-web/src/routes/onlyoffice.rs:325-630` 直接渲染 `/onlyoffice`,通过 DocsAPI 创建编辑器,未嵌入正文主编辑画布。
|
||||
- 关键风险是写回链 owner 分裂:Rust 主入口 no-op 与 legacy Next 真写回并存,见 F-01。
|
||||
|
||||
### Wolai-aline
|
||||
|
||||
- 流程文档严格要求“Wolai 基线 -> RED smoke -> 小范围实现 -> 本地验证 -> subagent 复测 -> 主线程截图复核”:`design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md`。
|
||||
- 本次不是具体 Wolai 对标实现任务,未执行浏览器对标;未发现该流程被错误提升为产品架构来源。
|
||||
|
||||
### SiYuan Reference
|
||||
|
||||
- `design/09-siyuan-reference/process/9-siyuan-reference-boundary-and-adoption-v1.md` 的边界与当前主线一致:思源只作为产品能力与交互参考,不替代 `tree-first graph kernel`、Rust kernel、Page Aggregate、tree command 与 tree realtime。
|
||||
- 当前只读检索未发现将 SiYuan `.sy`、SQL API 或前端 runtime 直接提升为 mnote 长期事实源的实现证据;若后续新增属性视图/数据库视图,应继续先做对象域评估。
|
||||
|
||||
## 建议优先级
|
||||
|
||||
1. P1:补齐或明确代理 OnlyOffice Rust callback/forcesave 写回链,避免主入口下保存成功但内容未持久化。
|
||||
2. P2:收口 Mindmap action 可用性,先修 `export` 映射与安全命令不一致,再继续扩展 UI 能力。
|
||||
3. P2:处理 Mindmap AI route:要么按 Rust bridge/CLI-first 重接 `mindmap_expand_node`,要么显式退役该 Next route。
|
||||
4. P2:明确 `wolai-backend` AI agent 的外置/对照定位与访问边界,避免与 `mnote-cli` 唯一执行面口径冲突。
|
||||
5. P3:清理 `design/90-reference` 的问答残留,并在引用规范里再次强调它不是 process/done 设计稿。
|
||||
|
||||
## 本次修改文件
|
||||
|
||||
- `/mnt/Data1T/mnote/design/10-review/04-secondary-domains-and-design-governance-review.md`
|
||||
@@ -0,0 +1,152 @@
|
||||
结论
|
||||
我基本同意你的方向,但要把“文件树为根源”说得更精确:不应让“文件树 UI 组件”成为真源,而应让 Rust kernel 中的 workspace resource tree / file tree projection 背后
|
||||
的资源层级 成为页面、附件、mindmap、OnlyOffice 等对象的组织根源。页面树则是从这棵资源/对象树派生出的导航投影,类似快捷方式、收藏视图或文档视图。
|
||||
|
||||
对当前 bug 来说,最核心的规则应固定为:
|
||||
|
||||
1. index.md 只代表页面正文,即 Page Aggregate body。
|
||||
2. mindmap 不是主编辑区正文替身,而是页面内某个 block 关联的 object / asset editor。
|
||||
3. 文件树点击 mindmap 应打开 mindmap object editor,不能被 index.md 吞掉,也不能用临时 mindmap-only bootstrap 覆盖真实页面正文。
|
||||
4. 页面树不应再持有独立结构真相,只显示“文档导航关系”;文件树显示“资源组织关系”。
|
||||
|
||||
主编辑区与插件
|
||||
主编辑区应该是一个工作区 tab host,而不是只等于文档正文编辑器。index.md 是其中一种 tab,mindmap、OnlyOffice、附件预览、代码附件都是其他 object tab/model。
|
||||
|
||||
Siyuan 这里值得借鉴:doc 和 asset 是不同 model/tab,asset 打开不伪装成 doc 正文;插件、widgets、assets、.sy 文档也在存储层分开。mnote 也应采用类似原则:插件/附
|
||||
件/mindmap 可以嵌入页面,但它们的持久化真源不能变成页面正文本身,只能通过 block-asset relation 被 Page Aggregate 引用。
|
||||
|
||||
文件树与页面树
|
||||
我建议改成三层表述:
|
||||
|
||||
- Resource Tree:长期 canonical 对象组织树,Rust kernel 持有语义,Convex 只是底层存储。
|
||||
- File Tree:Resource Tree 的主组织投影,展示页面文件夹、index.md、附件、mindmap、OnlyOffice、代码附件等。
|
||||
- Page Tree:面向阅读/导航的快捷投影,只显示页面关系,不拥有排序、父子、附件归属的最终真相。
|
||||
|
||||
这样你的“文件树为根源”可以落地,但不会和现有 tree-first graph kernel 冲突。更准确地说:文件树背后的 resource hierarchy 是根源,页面树是它的页面导航视图。
|
||||
|
||||
建议路线
|
||||
短期先修当前 bug:保留 /mindmap/{doc}/{asset} 作为 object editor,但彻底隔离草稿 key、保存 command 和 Page Aggregate body,smoke 必须真实网页验证“点击 mindmap、
|
||||
编辑长中文、保存、切回 index.md、再打开 mindmap 内容仍在”。
|
||||
|
||||
中期做主编辑区 asset tab:index.md | mindmap.json | xxx.docx,每个 tab 有自己的 object identity 和保存命令。
|
||||
|
||||
长期补 resource projection / block-asset relation:文件树 row、页面内 mindmap block、mindmap projection 必须来自同一关系索引,而不是前端临时按路由拼。
|
||||
|
||||
|
||||
|
||||
推荐路线
|
||||
我建议采用“分阶段收口 Resource Tree”的方案,不做一次性大重构。
|
||||
|
||||
可选方案有三种:
|
||||
|
||||
- 只修 mindmap bug:最快,但会继续留下页面树/文件树真源分裂。
|
||||
- 分阶段建立 Resource Tree -> File Tree/Page Tree projection:最稳,能解决当前 bug,也能承接附件、OnlyOffice、插件对象。
|
||||
- 全量重写 tree/page/resource 模型:理论干净,但当前工作区和主链都很活跃,风险太高。
|
||||
|
||||
我推荐第二种。
|
||||
|
||||
阶段 0:冻结合同
|
||||
先把 design/10-review/05-tree.md 升级为正式设计合同,建议后续移动或补一份到:
|
||||
|
||||
- design/04-tree-domain/process/4-24-resource-tree-filetree-pagetree-source-contract-v1.md
|
||||
- 或 design/05-editor-mainline/process/5-11-main-editor-object-tab-resource-tree-alignment-v1.md
|
||||
|
||||
合同里固定四句话:
|
||||
|
||||
- Resource Tree 是组织真源。
|
||||
- File Tree 是资源组织主投影。
|
||||
- Page Tree 是页面导航投影/快捷视图。
|
||||
- index.md、mindmap、OnlyOffice、附件是不同 object model,不能互相伪装。
|
||||
|
||||
阶段 1:先关闭当前 mindmap bug
|
||||
目标:文件树 mindmap 仍可编辑保存,但不污染 index.md。
|
||||
|
||||
重点文件:
|
||||
|
||||
- rust/crates/mnote-web/src/ssr/pages/layout.rs
|
||||
- rust/crates/mnote-web/src/routes/mindmap_shell.rs
|
||||
- rust/spikes/leptos-tiptap-spike/src/lib.rs
|
||||
- scripts/task169-mindmap-realtime-smoke.js
|
||||
|
||||
验收必须是真实浏览器链路:
|
||||
|
||||
1. 从文件树点击 mindmap asset。
|
||||
2. 进入明确的 mindmap object editor。
|
||||
3. 输入长中文并保存。
|
||||
4. 切回同页 index.md,正文仍是 Page Aggregate body。
|
||||
5. 再打开 mindmap,刚才内容仍在。
|
||||
6. 确认没有用真实 documentId 写 standalone 草稿 key。
|
||||
|
||||
阶段 2:补 Resource Projection 协议
|
||||
在 Rust 协议层明确资源节点和 block-asset 关系。
|
||||
|
||||
建议新增或扩展:
|
||||
|
||||
- ResourceNode
|
||||
- ResourceKind: page | index | mindmap | attachment | onlyoffice | code
|
||||
- ObjectIdentity: { objectKind, documentId, blockId?, assetId? }
|
||||
- BlockAssetRelation: { documentId, blockId, assetId, assetKind }
|
||||
|
||||
落点优先看:
|
||||
|
||||
- rust/crates/core-protocol/
|
||||
- rust/crates/bridge-runtime/
|
||||
- rust/crates/mnote-web/src/tree_shell/
|
||||
|
||||
这个阶段不要求立刻迁移所有数据,只要协议和 projection 能表达清楚。
|
||||
|
||||
阶段 3:让 File Tree 从 Resource Tree 派生
|
||||
当前 file tree 已有 resourceMeta、assetKind、file_tree projection 基础。下一步是收口为:
|
||||
|
||||
- 页面节点下面固定有 index.md
|
||||
- mindmap/附件/OnlyOffice 都是同一 Resource Tree 下的 child resource
|
||||
- 文件树 row 不再临时从多份前端数据拼出第二真相
|
||||
|
||||
同时保留 tree.asset.open,但它必须只表达 object open intent,不直接决定“把谁当正文”。
|
||||
|
||||
阶段 4:Page Tree 降级为导航投影
|
||||
Page Tree 不再拥有资源归属、附件归属、mindmap 归属。它只显示页面导航关系:
|
||||
|
||||
- 页面标题
|
||||
- 页面层级
|
||||
- 快捷入口/收藏/最近打开这类导航语义
|
||||
|
||||
如果页面树需要显示某个资源状态,也只读 Resource Projection,不自己维护。
|
||||
|
||||
阶段 5:主编辑区变成 Object Tab Host
|
||||
这是中期关键体验:
|
||||
|
||||
- index.md tab:加载 Page Aggregate body。
|
||||
- mindmap.json tab:加载 mindmap projection/command。
|
||||
- xxx.docx tab:打开 OnlyOffice object editor。
|
||||
- attachment tab:预览/代码编辑/外部打开。
|
||||
|
||||
这样 mindmap 不会被 index.md 吞掉,也不会伪装成正文。
|
||||
|
||||
阶段 6:命令面统一
|
||||
后续命令应逐步收口到:
|
||||
|
||||
- tree.asset.attach
|
||||
- tree.asset.detach
|
||||
- tree.resource.rename
|
||||
- tree.resource.move
|
||||
- page.body.save
|
||||
- mindmap.command.apply
|
||||
|
||||
关键规则:改资源关系走 tree/resource command,改页面正文走 page command,改导图内容走 mindmap command。
|
||||
|
||||
|
||||
落地入口
|
||||
|
||||
以上判断已落实到两个主线 done checklist:
|
||||
|
||||
- /mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md
|
||||
用于冻结 Resource Tree / File Tree / Page Tree 的真源合同和 projection / command 边界。
|
||||
- /mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md
|
||||
用于落实主编辑区 Object Tab、mindmap object editor、草稿隔离和真实浏览器 smoke 验收。
|
||||
|
||||
执行顺序固定为:
|
||||
|
||||
1. 先完成 4-24:协议、projection、command 边界必须能表达 `index.md`、mindmap、OnlyOffice、附件、代码附件的不同 object identity,并确认 Page Tree 只是页面导航投影。
|
||||
2. 再完成 5-12:主编辑区只消费 4-24 输出的 object identity / resourceMeta / open intent,先关闭 mindmap 污染 `index.md` 的 P0 bug,再推进 Object Tab Host。
|
||||
3. 验收以真实浏览器 smoke 为准,尤其是 `scripts/task169-mindmap-realtime-smoke.js` 覆盖“打开 mindmap、长中文编辑、保存、切页、回 `index.md`、再开 mindmap”的链路。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 10-review 审查总览
|
||||
|
||||
本目录汇总当前设计与实现的偏差审查结果。四份分域报告分别覆盖 Rust kernel/web、前端编辑器与树体验、Convex/realtime/storage、次级域与设计治理。
|
||||
|
||||
## 结论
|
||||
|
||||
当前主线方向总体没有跑偏,但实现仍停留在“主链已切、收口未完”的状态。最需要继续收口的是:
|
||||
|
||||
1. `Page Aggregate` 的单一真源边界
|
||||
2. `tree realtime` 的统一 live cache 与查询模型
|
||||
3. `documents.*` / `tree.*` / `page.*` 的 side effect 一致性
|
||||
4. legacy / compat / fallback 的显式退场边界
|
||||
|
||||
## 优先级摘要
|
||||
|
||||
### P0
|
||||
|
||||
- Rust page write 路由与 artifact 一致性
|
||||
- 搜索 / workspace shell / fallback 数据不要再伪装成真实主链
|
||||
|
||||
### P1
|
||||
|
||||
- `pageSubtree` 与正文编辑的关系需要明确
|
||||
- Sidebar 的 `initial / query / tree_stream` 三源选择要继续向统一 live cache 收口
|
||||
- `PageAggregateSource` 的 provenance 口径要和真实读链对齐
|
||||
- Convex overview 查询要避免全量扫描放大
|
||||
|
||||
### P2
|
||||
|
||||
- 继续清理或标注 legacy compat、debug host、stale mapping
|
||||
- 对 `design/90-reference` 和次级参考材料保持只读、非上位来源定位
|
||||
|
||||
## 报告文件
|
||||
|
||||
- [Rust Kernel / Web 实现偏差审查](./01-rust-kernel-web-review.md)
|
||||
- [前端编辑器 / 树体验实现偏差审查](./02-frontend-editor-tree-review.md)
|
||||
- [Convex / Realtime / Storage 实现偏差审查](./03-convex-realtime-storage-review.md)
|
||||
- [次级域与设计治理实现偏差审查](./04-secondary-domains-and-design-governance-review.md)
|
||||
|
||||
## 统一判断
|
||||
|
||||
当前最准确的描述不是“已经完成单一真源收口”,而是:
|
||||
|
||||
> Rust 已经持有主语义主导权,前端与 Convex 也已切到新主线,但 projection、command、realtime、fallback 仍有若干兼容态残留,需要继续按优先级收口。
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
# design 设计稿索引
|
||||
|
||||
> 更新时间:2026-04-20
|
||||
> 更新时间:2026-05-11
|
||||
>
|
||||
> 状态口径以当前仓库真实代码为准:
|
||||
> - `[done]`:对应阶段或收口目标已经在当前主线代码中成立
|
||||
@@ -36,6 +36,10 @@
|
||||
7. `07-ai/`
|
||||
- `process/` 放推进中的主线稿
|
||||
- `done/` 放已在真实代码中成立的主线稿
|
||||
8. `08-wolai-aline-test-flow/`
|
||||
- `process/` 放推进中的测试流程与对标执行稿
|
||||
9. `09-siyuan-reference/`
|
||||
- `process/` 放思源参考、借鉴边界与能力盘点稿
|
||||
|
||||
## 迁移规则
|
||||
|
||||
|
||||
@@ -253,9 +253,9 @@
|
||||
- [x] adapter 初始化时请求 `mindmap.simple_mind_map_scene.get`,把 projection 的 `root/layout/theme/themeConfig/view/config` 传给真实 `simple-mind-map`。
|
||||
- [x] adapter 内部真实调用 `new MindMap({ el, data, layout, theme, themeConfig, viewData, config })`。
|
||||
- [x] NodeView 销毁时调用 bridge `destroy()`,清理 runtime、监听器和 DOM。
|
||||
- [ ] `data_change` / `view_data_change` 通过 `mindmap-command-diff.ts` 转换为 kernel command 或 `compatPayload.patch`,再调用 `mindmap.command.apply`。
|
||||
- [x] `data_change` / `view_data_change` 通过 `mindmap-command-diff.ts` 转换为 kernel command 或 `compatPayload.patch`,再调用 `mindmap.command.apply`。
|
||||
- [x] command 成功后刷新 adapter projection 或同步 runtime revision;失败时显示 `command_failed`,不得伪装保存成功。
|
||||
- [ ] toolbar 的“子节点 / 同级节点 / 删除 / 回根 / 缩放”优先调用 runtime command,再经 command bridge 回写 kernel。
|
||||
- [x] toolbar 的“子节点 / 同级节点 / 删除 / 回根 / 缩放”优先调用 runtime command,再经 command bridge 回写 kernel。
|
||||
- [x] 右侧栏和底部栏只保留第一阶段 chrome,不再占据画布主要宽度;视觉对齐 `image copy 60.png` 的内嵌 KMind 工作台。
|
||||
- [x] 删除或降级旧 `.mnote-mindmap-node`、手写 `mindmap-edge`、固定 `svg viewBox` 成为 fallback/debug,不作为默认主链。
|
||||
- [x] 更新 smoke:禁止以手写 `.mnote-mindmap-node` 和 `path[data-testid="mindmap-edge"]` 作为成功条件。
|
||||
|
||||
@@ -6,6 +6,10 @@ services:
|
||||
image: ghcr.io/get-convex/convex-backend@sha256:2143ad479a997802e74ac52f5f1ce3d6e75309b1965e36069d1c939166e210eb
|
||||
stop_grace_period: 10s
|
||||
stop_signal: SIGINT
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
ports:
|
||||
- "3210:3210"
|
||||
- "3211:3211"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- generic [ref=e2] [box=0,13,1440,39]: "{\"ok\":false,\"code\":\"convex_upstream_error\",\"message\":\"Convex mutation 失败\",\"requestId\":\"req_1778542522227_65\",\"traceId\":\"trace_1778542522227_66\"}"
|
||||
@@ -13,10 +13,11 @@ use core_protocol::{
|
||||
DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand,
|
||||
EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand,
|
||||
GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge,
|
||||
KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge,
|
||||
KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
||||
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
|
||||
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
|
||||
KernelAuditStamp, KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode,
|
||||
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode,
|
||||
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit,
|
||||
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata,
|
||||
KernelNodeType, KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind,
|
||||
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
@@ -5547,7 +5548,7 @@ fn build_mindmap_kernel_projection_result(
|
||||
associative_lines: collect_mindmap_associative_lines(data),
|
||||
layout: read_mindmap_value_field(data, &["layout", "layoutHints", "layout_hints"])
|
||||
.unwrap_or_else(|| json!("logicalStructure")),
|
||||
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("classic")),
|
||||
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("default")),
|
||||
view: read_mindmap_value_field(data, &["view"]).unwrap_or_else(|| json!({})),
|
||||
capabilities: MindmapKernelCapabilities {
|
||||
can_edit: true,
|
||||
@@ -5564,6 +5565,45 @@ fn build_mindmap_kernel_projection_result(
|
||||
})
|
||||
}
|
||||
|
||||
fn default_mindmap_theme_config() -> Value {
|
||||
json!({
|
||||
"lineColor": "#7aa2ff",
|
||||
"lineStyle": "curve",
|
||||
"rootLineKeepSameInCurve": true,
|
||||
"rootLineStartPositionKeepSameInCurve": true,
|
||||
"generalizationLineColor": "#ef6a5b",
|
||||
"backgroundColor": "#f6f8fc",
|
||||
"root": {
|
||||
"fillColor": "#e25563",
|
||||
"color": "#ffffff",
|
||||
"fontWeight": "bold",
|
||||
"borderColor": "transparent",
|
||||
"borderWidth": 0,
|
||||
"borderRadius": 8
|
||||
},
|
||||
"second": {
|
||||
"fillColor": "#4f7df3",
|
||||
"color": "#ffffff",
|
||||
"borderColor": "transparent",
|
||||
"borderWidth": 0,
|
||||
"borderRadius": 8
|
||||
},
|
||||
"node": {
|
||||
"fillColor": "transparent",
|
||||
"color": "#315aa9",
|
||||
"borderColor": "transparent",
|
||||
"borderWidth": 0
|
||||
},
|
||||
"generalization": {
|
||||
"fillColor": "#ffffff",
|
||||
"color": "#ef6a5b",
|
||||
"borderColor": "#ef6a5b",
|
||||
"borderWidth": 1,
|
||||
"borderRadius": 8
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_mindmap_adapter_projection_result(
|
||||
data: &Value,
|
||||
mindmap_id: &str,
|
||||
@@ -5577,9 +5617,9 @@ fn build_mindmap_adapter_projection_result(
|
||||
})?,
|
||||
layout: read_mindmap_value_field(data, &["layout", "layoutHints", "layout_hints"])
|
||||
.unwrap_or_else(|| json!("logicalStructure")),
|
||||
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("classic")),
|
||||
theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("default")),
|
||||
theme_config: read_mindmap_value_field(data, &["themeConfig", "theme_config"])
|
||||
.unwrap_or_else(|| json!({})),
|
||||
.unwrap_or_else(default_mindmap_theme_config),
|
||||
view: read_mindmap_value_field(data, &["view"]).unwrap_or_else(|| json!({})),
|
||||
config: read_mindmap_value_field(data, &["config"]).unwrap_or_else(|| json!({})),
|
||||
compat_payload: read_mindmap_value_field(data, &["compatPayload", "compat_payload"])
|
||||
@@ -7075,6 +7115,33 @@ fn is_book_asset(file_name: &str, mime_type: &str) -> bool {
|
||||
.any(|suffix| lowered_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
fn is_onlyoffice_asset(file_name: &str, mime_type: &str) -> bool {
|
||||
let lowered_mime = mime_type.trim().to_ascii_lowercase();
|
||||
let lowered_name = file_name.trim().to_ascii_lowercase();
|
||||
lowered_mime.contains("officedocument")
|
||||
|| lowered_mime.contains("msword")
|
||||
|| lowered_mime.contains("ms-excel")
|
||||
|| lowered_mime.contains("ms-powerpoint")
|
||||
|| [
|
||||
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp",
|
||||
]
|
||||
.iter()
|
||||
.any(|suffix| lowered_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
fn is_code_asset(file_name: &str, mime_type: &str) -> bool {
|
||||
let lowered_mime = mime_type.trim().to_ascii_lowercase();
|
||||
let lowered_name = file_name.trim().to_ascii_lowercase();
|
||||
lowered_mime.starts_with("text/")
|
||||
&& [
|
||||
".c", ".cc", ".cpp", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js",
|
||||
".jsx", ".json", ".kt", ".lua", ".md", ".py", ".rs", ".sh", ".sql", ".toml",
|
||||
".ts", ".tsx", ".xml", ".yaml", ".yml",
|
||||
]
|
||||
.iter()
|
||||
.any(|suffix| lowered_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
fn classify_asset_kind(
|
||||
asset_type: &str,
|
||||
file_name: &str,
|
||||
@@ -7111,7 +7178,17 @@ fn classify_asset_kind(
|
||||
|
||||
fn classify_asset_resource_kind(
|
||||
asset_kind: &KernelProjectionAssetKind,
|
||||
file_name: &str,
|
||||
mime_type: &str,
|
||||
) -> KernelProjectionResourceKind {
|
||||
if matches!(asset_kind, KernelProjectionAssetKind::File) {
|
||||
if is_onlyoffice_asset(file_name, mime_type) {
|
||||
return KernelProjectionResourceKind::OnlyOffice;
|
||||
}
|
||||
if is_code_asset(file_name, mime_type) {
|
||||
return KernelProjectionResourceKind::Code;
|
||||
}
|
||||
}
|
||||
match asset_kind {
|
||||
KernelProjectionAssetKind::Mindmap => KernelProjectionResourceKind::Mindmap,
|
||||
KernelProjectionAssetKind::Table => KernelProjectionResourceKind::Table,
|
||||
@@ -7403,6 +7480,7 @@ fn make_projection_resource_meta(
|
||||
resource_kind: KernelProjectionResourceKind,
|
||||
document_id: Option<String>,
|
||||
asset_id: Option<String>,
|
||||
block_id: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
asset_kind: Option<KernelProjectionAssetKind>,
|
||||
icon_hint: &str,
|
||||
@@ -7418,6 +7496,34 @@ fn make_projection_resource_meta(
|
||||
) {
|
||||
extra.insert("source".into(), source);
|
||||
}
|
||||
let object_kind = match (&resource_kind, &asset_kind, asset_id.as_ref()) {
|
||||
(KernelProjectionResourceKind::Document, _, _) => KernelObjectKind::Page,
|
||||
(KernelProjectionResourceKind::Index, _, _) => KernelObjectKind::Index,
|
||||
(KernelProjectionResourceKind::Mindmap, _, _) => KernelObjectKind::Mindmap,
|
||||
(KernelProjectionResourceKind::OnlyOffice, _, _) => KernelObjectKind::OnlyOffice,
|
||||
(KernelProjectionResourceKind::Code, _, _) => KernelObjectKind::Code,
|
||||
(_, Some(KernelProjectionAssetKind::Mindmap), _) => KernelObjectKind::Mindmap,
|
||||
(_, _, Some(_)) => KernelObjectKind::Attachment,
|
||||
_ => KernelObjectKind::Page,
|
||||
};
|
||||
let object_identity = Some(KernelObjectIdentity {
|
||||
object_kind,
|
||||
document_id: document_id.clone(),
|
||||
block_id: block_id.clone(),
|
||||
asset_id: asset_id.clone(),
|
||||
});
|
||||
let block_asset_relation =
|
||||
match (document_id.clone(), block_id, asset_id.clone(), asset_kind.clone()) {
|
||||
(Some(document_id), Some(block_id), Some(asset_id), Some(asset_kind)) => {
|
||||
Some(KernelBlockAssetRelation {
|
||||
document_id,
|
||||
block_id,
|
||||
asset_id,
|
||||
asset_kind,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
KernelProjectionResourceMeta {
|
||||
resource_kind: Some(resource_kind),
|
||||
document_id,
|
||||
@@ -7425,6 +7531,8 @@ fn make_projection_resource_meta(
|
||||
workspace_id,
|
||||
asset_kind,
|
||||
icon_hint: Some(icon_hint.into()),
|
||||
object_identity,
|
||||
block_asset_relation,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
@@ -7433,6 +7541,7 @@ fn make_projection_resource_meta(
|
||||
struct NormalizedFileTreeAsset {
|
||||
id: String,
|
||||
document_id: String,
|
||||
block_id: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
title: String,
|
||||
resource_kind: KernelProjectionResourceKind,
|
||||
@@ -7451,7 +7560,7 @@ fn infer_file_tree_asset_shape(
|
||||
) {
|
||||
let asset_kind = classify_asset_kind(asset_type, file_name, mime_type);
|
||||
(
|
||||
classify_asset_resource_kind(&asset_kind),
|
||||
classify_asset_resource_kind(&asset_kind, file_name, mime_type),
|
||||
asset_kind.clone(),
|
||||
classify_asset_icon_hint(&asset_kind),
|
||||
)
|
||||
@@ -7472,6 +7581,7 @@ fn normalize_file_tree_asset(value: &Value) -> Option<NormalizedFileTreeAsset> {
|
||||
Some(NormalizedFileTreeAsset {
|
||||
id,
|
||||
document_id,
|
||||
block_id: string_field(value, "block_id").or_else(|| string_field(value, "blockId")),
|
||||
workspace_id: string_field(value, "workspace_id")
|
||||
.or_else(|| string_field(value, "workspaceId")),
|
||||
title: file_name,
|
||||
@@ -7651,6 +7761,7 @@ fn build_file_tree_projection_result(
|
||||
KernelProjectionResourceKind::Document,
|
||||
Some(node.id.clone()),
|
||||
None,
|
||||
None,
|
||||
workspace_id.clone(),
|
||||
None,
|
||||
"page",
|
||||
@@ -7682,6 +7793,7 @@ fn build_file_tree_projection_result(
|
||||
KernelProjectionResourceKind::Index,
|
||||
Some(node.id.clone()),
|
||||
None,
|
||||
None,
|
||||
workspace_id.clone(),
|
||||
None,
|
||||
"index",
|
||||
@@ -7728,6 +7840,7 @@ fn build_file_tree_projection_result(
|
||||
asset.resource_kind.clone(),
|
||||
Some(asset.document_id.clone()),
|
||||
Some(asset.id.clone()),
|
||||
asset.block_id.clone(),
|
||||
asset.workspace_id.clone(),
|
||||
Some(KernelProjectionAssetKind::Mindmap),
|
||||
"mindmap",
|
||||
@@ -7768,6 +7881,7 @@ fn build_file_tree_projection_result(
|
||||
child_asset.resource_kind.clone(),
|
||||
Some(child_asset.document_id.clone()),
|
||||
Some(child_asset.id.clone()),
|
||||
child_asset.block_id.clone(),
|
||||
child_asset.workspace_id.clone(),
|
||||
Some(child_asset.asset_kind.clone()),
|
||||
child_asset.icon_hint,
|
||||
@@ -7808,6 +7922,7 @@ fn build_file_tree_projection_result(
|
||||
asset.resource_kind.clone(),
|
||||
Some(asset.document_id.clone()),
|
||||
Some(asset.id.clone()),
|
||||
asset.block_id.clone(),
|
||||
asset.workspace_id.clone(),
|
||||
Some(asset.asset_kind.clone()),
|
||||
asset.icon_hint,
|
||||
@@ -8141,6 +8256,7 @@ fn build_kernel_projection_result(
|
||||
KernelProjectionResourceKind::Document,
|
||||
Some(node.id.clone()),
|
||||
None,
|
||||
None,
|
||||
node.workspace_id.clone(),
|
||||
None,
|
||||
"page",
|
||||
@@ -9189,10 +9305,24 @@ fn execute_command(
|
||||
source: workspace_source_value(&command_wire.source),
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"docId": payload.document_id,
|
||||
"mindmapId": payload.mindmap_id,
|
||||
"docId": payload.document_id.clone(),
|
||||
"mindmapId": payload.mindmap_id.clone(),
|
||||
"data": payload.data,
|
||||
"createOnly": payload.create_only.unwrap_or(false),
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.put",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.put"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.put",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.put",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -9229,11 +9359,25 @@ fn execute_command(
|
||||
source: workspace_source_value(&command_wire.source),
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"documentId": payload.document_id,
|
||||
"mindmapId": payload.mindmap_id,
|
||||
"documentId": payload.document_id.clone(),
|
||||
"mindmapId": payload.mindmap_id.clone(),
|
||||
"commands": payload.commands,
|
||||
"projectionRevision": payload.projection_revision,
|
||||
"canonicalCommand": "mindmap.command.apply",
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.command.apply",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.updated"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.updated",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.command.apply",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -9265,8 +9409,22 @@ fn execute_command(
|
||||
source: workspace_source_value(&command_wire.source),
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"docId": payload.document_id,
|
||||
"mindmapId": payload.mindmap_id,
|
||||
"docId": payload.document_id.clone(),
|
||||
"mindmapId": payload.mindmap_id.clone(),
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.delete",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.deleted"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.deleted",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.delete",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -9299,8 +9457,22 @@ fn execute_command(
|
||||
source: workspace_source_value(&command_wire.source),
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"docId": payload.document_id,
|
||||
"mindmapId": payload.mindmap_id,
|
||||
"docId": payload.document_id.clone(),
|
||||
"mindmapId": payload.mindmap_id.clone(),
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.restore",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.restored"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.restored",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.restore",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -10036,6 +10208,7 @@ where
|
||||
|
||||
fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
|
||||
match raw_type {
|
||||
"mindmap" => EditorBlockType::Mindmap,
|
||||
"heading" => EditorBlockType::Heading,
|
||||
"bullet_list_item" | "bullet_list" | "bullet-list" => EditorBlockType::BulletListItem,
|
||||
"numbered_list_item" | "ordered_list" | "ordered-list" => EditorBlockType::NumberedListItem,
|
||||
@@ -10058,11 +10231,9 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
|
||||
let raw_type = read_trimmed_string_field(block, &["blockType", "type"])
|
||||
.unwrap_or_else(|| "paragraph".into())
|
||||
.to_lowercase();
|
||||
let normalized_block_type = normalize_editor_block_type_for_save(&raw_type);
|
||||
let mut props = BlockProps::default();
|
||||
if matches!(
|
||||
normalize_editor_block_type_for_save(&raw_type),
|
||||
EditorBlockType::Heading
|
||||
) {
|
||||
if matches!(normalized_block_type, EditorBlockType::Heading) {
|
||||
props.heading_level = block
|
||||
.get("props")
|
||||
.and_then(Value::as_object)
|
||||
@@ -10076,20 +10247,14 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
|
||||
.and_then(|map| map.get("collapsed"))
|
||||
.and_then(Value::as_bool);
|
||||
}
|
||||
if matches!(
|
||||
normalize_editor_block_type_for_save(&raw_type),
|
||||
EditorBlockType::Todo
|
||||
) {
|
||||
if matches!(normalized_block_type, EditorBlockType::Todo) {
|
||||
props.checked = block
|
||||
.get("props")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|map| map.get("checked"))
|
||||
.and_then(Value::as_bool);
|
||||
}
|
||||
if matches!(
|
||||
normalize_editor_block_type_for_save(&raw_type),
|
||||
EditorBlockType::CodeBlock
|
||||
) {
|
||||
if matches!(normalized_block_type, EditorBlockType::CodeBlock) {
|
||||
props.language = block
|
||||
.get("props")
|
||||
.and_then(Value::as_object)
|
||||
@@ -10121,15 +10286,72 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
|
||||
{
|
||||
props.extra.insert("tiptapTocNode".into(), tiptap_toc);
|
||||
}
|
||||
if matches!(normalized_block_type, EditorBlockType::Mindmap) {
|
||||
let props_map = block.get("props").and_then(Value::as_object);
|
||||
let legacy_data = props_map.and_then(|map| map.get("data"));
|
||||
let data_object = legacy_data.and_then(Value::as_object);
|
||||
if let Some(mindmap_id) = props_map
|
||||
.and_then(|map| map.get("mindmapId").or_else(|| map.get("mindmap_id")))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
data_object.and_then(|map| {
|
||||
map.get("mindmapId")
|
||||
.or_else(|| map.get("mindmap_id"))
|
||||
.or_else(|| map.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
props
|
||||
.extra
|
||||
.insert("mindmapId".into(), Value::String(mindmap_id.to_string()));
|
||||
} else {
|
||||
props
|
||||
.extra
|
||||
.insert("mindmapId".into(), Value::String(block_id.clone()));
|
||||
}
|
||||
if let Some(root_node_id) = props_map
|
||||
.and_then(|map| map.get("rootNodeId").or_else(|| map.get("root_node_id")))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
data_object.and_then(|map| {
|
||||
map.get("rootNodeId")
|
||||
.or_else(|| map.get("root_node_id"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
props
|
||||
.extra
|
||||
.insert("rootNodeId".into(), Value::String(root_node_id.to_string()));
|
||||
}
|
||||
if let Some(projection_version) = props_map
|
||||
.and_then(|map| map.get("projectionVersion"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
props.extra.insert(
|
||||
"projectionVersion".into(),
|
||||
Value::Number(projection_version.into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
let text = read_trimmed_string_field(block, &["content"])
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| extract_inline_text(block));
|
||||
|
||||
EditorBlock {
|
||||
block_id,
|
||||
block_type: normalize_editor_block_type_for_save(&raw_type),
|
||||
block_type: normalized_block_type.clone(),
|
||||
props,
|
||||
content_nodes: build_text_content_nodes(&text),
|
||||
content_nodes: if matches!(normalized_block_type, EditorBlockType::Mindmap) {
|
||||
vec![]
|
||||
} else {
|
||||
build_text_content_nodes(&text)
|
||||
},
|
||||
child_block_ids: vec![],
|
||||
}
|
||||
}
|
||||
@@ -10156,6 +10378,7 @@ fn normalize_save_editor_document(
|
||||
) -> Result<EditorBlockDocument, BridgeError> {
|
||||
if let Some(editor_document) = payload.editor_document.clone() {
|
||||
if let Ok(mut parsed) = serde_json::from_value::<EditorBlockDocument>(editor_document) {
|
||||
hydrate_editor_document_props_from_raw(&mut parsed, payload.editor_document.as_ref());
|
||||
if parsed.document_id.trim().is_empty() {
|
||||
parsed.document_id = payload.document_id.clone();
|
||||
}
|
||||
@@ -10189,6 +10412,80 @@ fn normalize_save_editor_document(
|
||||
))
|
||||
}
|
||||
|
||||
fn hydrate_editor_document_props_from_raw(parsed: &mut EditorBlockDocument, raw: Option<&Value>) {
|
||||
let Some(raw_blocks) = raw
|
||||
.and_then(|value| value.get("blocks"))
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (index, block) in parsed.blocks.iter_mut().enumerate() {
|
||||
if !matches!(block.block_type, EditorBlockType::Mindmap) {
|
||||
continue;
|
||||
}
|
||||
let Some(raw_block) = raw_blocks
|
||||
.iter()
|
||||
.find(|candidate| {
|
||||
read_trimmed_string_field(candidate, &["blockId", "block_id"]).as_deref()
|
||||
== Some(block.block_id.as_str())
|
||||
})
|
||||
.or_else(|| raw_blocks.get(index))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
hydrate_mindmap_block_props_from_raw(block, raw_block);
|
||||
}
|
||||
}
|
||||
|
||||
fn hydrate_mindmap_block_props_from_raw(block: &mut EditorBlock, raw_block: &Value) {
|
||||
let props = raw_block.get("props").and_then(Value::as_object);
|
||||
let legacy_data = props
|
||||
.and_then(|map| map.get("data"))
|
||||
.and_then(Value::as_object);
|
||||
if let Some(mindmap_id) = props
|
||||
.and_then(|map| map.get("mindmapId").or_else(|| map.get("mindmap_id")))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
legacy_data.and_then(|map| {
|
||||
map.get("mindmapId")
|
||||
.or_else(|| map.get("mindmap_id"))
|
||||
.or_else(|| map.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
block
|
||||
.props
|
||||
.extra
|
||||
.insert("mindmapId".into(), Value::String(mindmap_id.to_string()));
|
||||
}
|
||||
for (raw_key, canonical_key) in [("rootNodeId", "rootNodeId"), ("root_node_id", "rootNodeId")] {
|
||||
if let Some(value) = props
|
||||
.and_then(|map| map.get(raw_key))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| legacy_data.and_then(|map| map.get(raw_key).and_then(Value::as_str)))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
block
|
||||
.props
|
||||
.extra
|
||||
.insert(canonical_key.into(), Value::String(value.to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(projection_version) = props
|
||||
.and_then(|map| map.get("projectionVersion"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
block.props.extra.insert(
|
||||
"projectionVersion".into(),
|
||||
Value::Number(projection_version.into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
|
||||
let mut props = serde_json::Map::new();
|
||||
match block.block_type {
|
||||
@@ -10232,6 +10529,23 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
|
||||
props.insert("tiptapTocNode".into(), tiptap_toc.clone());
|
||||
}
|
||||
}
|
||||
EditorBlockType::Mindmap => {
|
||||
if let Some(mindmap_id) = block.props.extra.get("mindmapId").and_then(Value::as_str) {
|
||||
props.insert("mindmapId".into(), json!(mindmap_id));
|
||||
}
|
||||
if let Some(root_node_id) = block.props.extra.get("rootNodeId").and_then(Value::as_str)
|
||||
{
|
||||
props.insert("rootNodeId".into(), json!(root_node_id));
|
||||
}
|
||||
if let Some(projection_version) = block
|
||||
.props
|
||||
.extra
|
||||
.get("projectionVersion")
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
props.insert("projectionVersion".into(), json!(projection_version));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(text_align) = block
|
||||
@@ -10255,6 +10569,7 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
|
||||
fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str {
|
||||
match block.block_type {
|
||||
EditorBlockType::Paragraph => "paragraph",
|
||||
EditorBlockType::Mindmap => "mindmap",
|
||||
EditorBlockType::Heading => "heading",
|
||||
EditorBlockType::BulletListItem => "bullet_list_item",
|
||||
EditorBlockType::NumberedListItem => "numbered_list_item",
|
||||
@@ -10302,6 +10617,8 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value
|
||||
"props": legacy_props_from_editor_block(block),
|
||||
"content": if matches!(block.block_type, EditorBlockType::Divider) {
|
||||
Value::Array(Vec::new())
|
||||
} else if matches!(block.block_type, EditorBlockType::Mindmap) {
|
||||
Value::String(String::new())
|
||||
} else {
|
||||
Value::String(legacy_text_from_editor_block(block))
|
||||
},
|
||||
@@ -11429,6 +11746,14 @@ mod tests {
|
||||
"text": "新标题"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"]["eventType"],
|
||||
json!("tree.resource.mindmap.updated")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"]["kind"],
|
||||
json!("resync_required")
|
||||
);
|
||||
}
|
||||
RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => {
|
||||
panic!("expected command plan")
|
||||
@@ -11726,16 +12051,44 @@ mod tests {
|
||||
match plan {
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(plan.function_name, "mindmaps:put");
|
||||
assert_eq!(plan.args_json["docId"], json!("doc_1"));
|
||||
assert_eq!(plan.args_json["mindmapId"], json!("mind_1"));
|
||||
assert_eq!(
|
||||
plan.args_json,
|
||||
plan.args_json["data"],
|
||||
json!({
|
||||
"docId": "doc_1",
|
||||
"mindmapId": "mind_1",
|
||||
"data": {
|
||||
"data": {"text": "中心主题"},
|
||||
"children": [],
|
||||
},
|
||||
"createOnly": true,
|
||||
"data": {"text": "中心主题"},
|
||||
"children": [],
|
||||
})
|
||||
);
|
||||
assert_eq!(plan.args_json["createOnly"], json!(true));
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "mindmap.put",
|
||||
"documentId": "doc_1",
|
||||
"blockId": "mind_1",
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "tree.resource.mindmap.put",
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "mindmap.put",
|
||||
"documentId": "doc_1",
|
||||
"blockId": "mind_1",
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -12317,6 +12670,178 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_save_command_plan_preserves_mindmap_placeholder() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "documents.save".into(),
|
||||
command_id: "cmd_save_mindmap".into(),
|
||||
idempotency_key: Some("idem_save_mindmap".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "rust-web".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 6,
|
||||
"content": [],
|
||||
"tiptapDocument": {
|
||||
"type": "doc",
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"blockId": "mind_1",
|
||||
"mnoteBlockType": "mindmap",
|
||||
"mindmapId": "mind_1",
|
||||
"rootNodeId": "root",
|
||||
"projectionVersion": 1
|
||||
}
|
||||
}]
|
||||
},
|
||||
"conflictDetectionKey": "doc_1:6"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存导图占位".into()),
|
||||
refs: vec!["phase6-mindmap".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("documents.save mindmap plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
|
||||
Some(&json!("mindmap"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/type"),
|
||||
Some(&json!("mindmap"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/id"),
|
||||
Some(&json!("mind_1"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/props/mindmapId"),
|
||||
Some(&json!("mind_1"))
|
||||
);
|
||||
assert_eq!(plan.args_json.pointer("/content/0/props/data"), None);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/content"),
|
||||
Some(&json!(""))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_save_command_plan_preserves_mindmap_props_from_editor_document() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "page.body.save".into(),
|
||||
command_id: "cmd_save_mindmap_editor_document".into(),
|
||||
idempotency_key: Some("idem_save_mindmap_editor_document".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "rust-web".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 7,
|
||||
"editorDocument": {
|
||||
"documentId": "doc_1",
|
||||
"rootBlockIds": ["block-1"],
|
||||
"blocks": [{
|
||||
"blockId": "block-1",
|
||||
"blockType": "mindmap",
|
||||
"props": {
|
||||
"data": null,
|
||||
"mindmapId": "mind_1",
|
||||
"rootNodeId": "root",
|
||||
"projectionVersion": 1
|
||||
},
|
||||
"contentNodes": [],
|
||||
"childBlockIds": []
|
||||
}]
|
||||
},
|
||||
"content": [],
|
||||
"tiptapDocument": {
|
||||
"type": "doc",
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"blockId": "block-1",
|
||||
"mnoteBlockType": "mindmap",
|
||||
"mindmapId": "mind_1",
|
||||
"rootNodeId": "root",
|
||||
"projectionVersion": 1
|
||||
}
|
||||
}]
|
||||
},
|
||||
"conflictDetectionKey": "doc_1:7"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存 editorDocument 导图占位".into()),
|
||||
refs: vec!["task169-mindmap-realtime-smoke".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("page.body.save mindmap editorDocument plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/props/mindmapId"),
|
||||
Some(&json!("mind_1"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/props/rootNodeId"),
|
||||
Some(&json!("root"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/props/projectionVersion"),
|
||||
Some(&json!(1))
|
||||
);
|
||||
assert_eq!(plan.args_json.pointer("/content/0/props/data"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_save_command_plan_preserves_tiptap_image() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
@@ -16219,6 +16744,7 @@ mod tests {
|
||||
"id": "mind_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"block_id": "block_mind_1",
|
||||
"asset_type": "mindmap",
|
||||
"file_name": "roadmap.json",
|
||||
"mime_type": "application/json",
|
||||
@@ -16293,6 +16819,15 @@ mod tests {
|
||||
item_by_row_id["index:page_root"]["resourceMeta"]["resourceKind"],
|
||||
json!("index")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["index:page_root"]["resourceMeta"]["objectIdentity"],
|
||||
json!({
|
||||
"objectKind": "index",
|
||||
"documentId": "page_root",
|
||||
"blockId": null,
|
||||
"assetId": null
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["index:page_root"]["resourceMeta"]["extra"]["source"],
|
||||
json!({
|
||||
@@ -16333,6 +16868,24 @@ mod tests {
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["assetKind"],
|
||||
json!("mindmap")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["objectIdentity"],
|
||||
json!({
|
||||
"objectKind": "mindmap",
|
||||
"documentId": "page_root",
|
||||
"blockId": "block_mind_1",
|
||||
"assetId": "mind_1"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["blockAssetRelation"],
|
||||
json!({
|
||||
"documentId": "page_root",
|
||||
"blockId": "block_mind_1",
|
||||
"assetId": "mind_1",
|
||||
"assetKind": "mindmap"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:asset_ref_1"]["parentNodeId"],
|
||||
json!("asset-folder:mind_1")
|
||||
@@ -16566,6 +17119,108 @@ mod tests {
|
||||
assert!(!row_ids.contains(&"asset:asset_rust_child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_file_tree_projection_separates_attachment_object_identities() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
context: demo_context(),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.project_view".into(),
|
||||
payload: json!({
|
||||
"projection": "file_tree",
|
||||
"workspaceId": "ws_1",
|
||||
"rootNodeId": "page_root",
|
||||
"depth": 2,
|
||||
"includeEdges": true,
|
||||
"nodeTypes": ["page"],
|
||||
}),
|
||||
},
|
||||
data: Some(json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "page_root",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "根页面",
|
||||
"parent_id": null,
|
||||
"sort_order": 0,
|
||||
"is_starred": true,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"media_assets": [
|
||||
{
|
||||
"id": "office_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"block_id": "block_office_1",
|
||||
"asset_type": "file",
|
||||
"file_name": "contract.docx",
|
||||
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
},
|
||||
{
|
||||
"id": "code_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"block_id": "block_code_1",
|
||||
"asset_type": "file",
|
||||
"file_name": "main.rs",
|
||||
"mime_type": "text/rust"
|
||||
},
|
||||
{
|
||||
"id": "image_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"block_id": "block_image_1",
|
||||
"asset_type": "file",
|
||||
"file_name": "cover.png",
|
||||
"mime_type": "image/png"
|
||||
}
|
||||
],
|
||||
"mindmap_assets": [],
|
||||
"table_assets": [],
|
||||
"mindmap_asset_children": {}
|
||||
})),
|
||||
})
|
||||
.expect("file_tree object identity projection should build");
|
||||
|
||||
let items = result["items"].as_array().expect("items should be array");
|
||||
let item_by_row_id = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.get("rowId")
|
||||
.and_then(Value::as_str)
|
||||
.map(|row_id| (row_id.to_string(), item))
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:office_1"]["resourceMeta"]["resourceKind"],
|
||||
json!("only_office")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:office_1"]["resourceMeta"]["objectIdentity"]["objectKind"],
|
||||
json!("only_office")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:code_1"]["resourceMeta"]["resourceKind"],
|
||||
json!("code")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:code_1"]["resourceMeta"]["objectIdentity"]["objectKind"],
|
||||
json!("code")
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["asset:image_1"]["resourceMeta"]["objectIdentity"],
|
||||
json!({
|
||||
"objectKind": "attachment",
|
||||
"documentId": "page_root",
|
||||
"blockId": "block_image_1",
|
||||
"assetId": "image_1"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_file_tree_projection_query_matches_index_resource() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
|
||||
@@ -54,6 +54,10 @@ mod tests {
|
||||
serde_json::to_string(&EditorBlockType::PageReference).unwrap(),
|
||||
"\"page_reference\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorBlockType::Mindmap).unwrap(),
|
||||
"\"mindmap\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditorBlockType::BlockReference).unwrap(),
|
||||
"\"block_reference\""
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::BTreeMap;
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditorBlockType {
|
||||
Paragraph,
|
||||
Mindmap,
|
||||
Heading,
|
||||
BulletListItem,
|
||||
NumberedListItem,
|
||||
|
||||
@@ -58,6 +58,16 @@ pub struct TiptapParagraphAttrs {
|
||||
pub block_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_align: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mnote_block_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mindmap_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub root_node_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub projection_version: Option<u64>,
|
||||
#[serde(flatten, default)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -371,6 +381,54 @@ fn props_with_text_align(text_align: Option<String>) -> BlockProps {
|
||||
props
|
||||
}
|
||||
|
||||
fn props_with_mindmap_attrs(attrs: &TiptapParagraphAttrs) -> BlockProps {
|
||||
let mut props = props_with_text_align(attrs.text_align.clone());
|
||||
if let Some(mindmap_id) = attrs
|
||||
.mindmap_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
props
|
||||
.extra
|
||||
.insert("mindmapId".into(), Value::String(mindmap_id.to_string()));
|
||||
}
|
||||
if let Some(root_node_id) = attrs
|
||||
.root_node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
props
|
||||
.extra
|
||||
.insert("rootNodeId".into(), Value::String(root_node_id.to_string()));
|
||||
}
|
||||
if let Some(projection_version) = attrs.projection_version {
|
||||
props.extra.insert(
|
||||
"projectionVersion".into(),
|
||||
Value::Number(projection_version.into()),
|
||||
);
|
||||
}
|
||||
for (key, value) in &attrs.extra {
|
||||
props.extra.insert(key.clone(), value.clone());
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
fn read_extra_string(props: &BlockProps, key: &str) -> Option<String> {
|
||||
props
|
||||
.extra
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_extra_u64(props: &BlockProps, key: &str) -> Option<u64> {
|
||||
props.extra.get(key).and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
impl EditorBlockDocumentTiptapBridge {
|
||||
pub fn to_tiptap_doc(
|
||||
document: &EditorBlockDocument,
|
||||
@@ -474,6 +532,11 @@ fn list_item_content_from_block(
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: None,
|
||||
mindmap_id: None,
|
||||
root_node_id: None,
|
||||
projection_version: None,
|
||||
extra: BTreeMap::new(),
|
||||
},
|
||||
content: inline_content,
|
||||
}];
|
||||
@@ -491,9 +554,40 @@ fn block_to_tiptap_node(
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: None,
|
||||
mindmap_id: None,
|
||||
root_node_id: None,
|
||||
projection_version: None,
|
||||
extra: BTreeMap::new(),
|
||||
},
|
||||
content,
|
||||
}),
|
||||
EditorBlockType::Mindmap => {
|
||||
let mut extra = block.props.extra.clone();
|
||||
extra.remove("textAlign");
|
||||
extra.remove("text_align");
|
||||
extra.remove("mindmapId");
|
||||
extra.remove("rootNodeId");
|
||||
extra.remove("projectionVersion");
|
||||
if !extra.contains_key("mnoteMindmapData") {
|
||||
if let Some(data) = block.props.extra.get("data") {
|
||||
extra.insert("mnoteMindmapData".into(), data.clone());
|
||||
}
|
||||
}
|
||||
Ok(TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: Some("mindmap".into()),
|
||||
mindmap_id: read_extra_string(&block.props, "mindmapId")
|
||||
.or_else(|| Some(block.block_id.clone())),
|
||||
root_node_id: read_extra_string(&block.props, "rootNodeId"),
|
||||
projection_version: read_extra_u64(&block.props, "projectionVersion"),
|
||||
extra,
|
||||
},
|
||||
content: Vec::new(),
|
||||
})
|
||||
}
|
||||
EditorBlockType::Heading => Ok(TiptapNode::Heading {
|
||||
attrs: TiptapHeadingAttrs {
|
||||
level: block.props.heading_level.unwrap_or(1),
|
||||
@@ -546,6 +640,11 @@ fn block_to_tiptap_node(
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: None,
|
||||
mindmap_id: None,
|
||||
root_node_id: None,
|
||||
projection_version: None,
|
||||
extra: BTreeMap::new(),
|
||||
},
|
||||
content,
|
||||
}],
|
||||
@@ -584,6 +683,21 @@ fn node_to_block(
|
||||
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
|
||||
let fallback_block_id = format!("block_{}", index + 1);
|
||||
match node {
|
||||
TiptapNode::Paragraph { attrs, .. }
|
||||
if attrs.mnote_block_type.as_deref() == Some("mindmap") =>
|
||||
{
|
||||
Ok(EditorBlock {
|
||||
block_id: attrs
|
||||
.block_id
|
||||
.clone()
|
||||
.or_else(|| attrs.mindmap_id.clone())
|
||||
.unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Mindmap,
|
||||
props: props_with_mindmap_attrs(attrs),
|
||||
content_nodes: vec![],
|
||||
child_block_ids: vec![],
|
||||
})
|
||||
}
|
||||
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Paragraph,
|
||||
@@ -1118,6 +1232,7 @@ mod tests {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("align_1".into()),
|
||||
text_align: Some("center".into()),
|
||||
..TiptapParagraphAttrs::default()
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "E20 center".into(),
|
||||
@@ -1143,4 +1258,34 @@ mod tests {
|
||||
};
|
||||
assert_eq!(attrs.text_align.as_deref(), Some("center"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_mindmap_paragraph_placeholder() {
|
||||
let doc = TiptapNode::Doc {
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("mind_1".into()),
|
||||
mnote_block_type: Some("mindmap".into()),
|
||||
mindmap_id: Some("mind_1".into()),
|
||||
root_node_id: Some("root".into()),
|
||||
projection_version: Some(1),
|
||||
..TiptapParagraphAttrs::default()
|
||||
},
|
||||
content: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let parsed = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_1", &doc)
|
||||
.expect("mindmap placeholder should convert to mindmap block");
|
||||
assert_eq!(parsed.root_block_ids, vec!["mind_1"]);
|
||||
assert_eq!(parsed.blocks[0].block_type, EditorBlockType::Mindmap);
|
||||
assert_eq!(
|
||||
parsed.blocks[0].props.extra.get("mindmapId"),
|
||||
Some(&serde_json::json!("mind_1"))
|
||||
);
|
||||
|
||||
let restored = EditorBlockDocumentTiptapBridge::to_tiptap_doc(&parsed)
|
||||
.expect("mindmap block should restore to paragraph placeholder");
|
||||
assert_eq!(restored, doc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ pub enum KernelProjectionResourceKind {
|
||||
Asset,
|
||||
AssetFolder,
|
||||
Mindmap,
|
||||
Attachment,
|
||||
OnlyOffice,
|
||||
Code,
|
||||
Table,
|
||||
Book,
|
||||
Pdf,
|
||||
@@ -131,6 +134,35 @@ pub enum KernelProjectionAssetKind {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KernelObjectKind {
|
||||
Page,
|
||||
Index,
|
||||
Mindmap,
|
||||
Attachment,
|
||||
OnlyOffice,
|
||||
Code,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelObjectIdentity {
|
||||
pub object_kind: KernelObjectKind,
|
||||
pub document_id: Option<String>,
|
||||
pub block_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelBlockAssetRelation {
|
||||
pub document_id: String,
|
||||
pub block_id: String,
|
||||
pub asset_id: String,
|
||||
pub asset_kind: KernelProjectionAssetKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KernelGraphDirection {
|
||||
@@ -367,6 +399,10 @@ pub struct KernelProjectionResourceMeta {
|
||||
pub workspace_id: Option<String>,
|
||||
pub asset_kind: Option<KernelProjectionAssetKind>,
|
||||
pub icon_hint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub object_identity: Option<KernelObjectIdentity>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub block_asset_relation: Option<KernelBlockAssetRelation>,
|
||||
#[serde(default)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
@@ -649,6 +685,8 @@ mod tests {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
asset_kind: Some(KernelProjectionAssetKind::File),
|
||||
icon_hint: Some("page".into()),
|
||||
object_identity: None,
|
||||
block_asset_relation: None,
|
||||
extra: BTreeMap::new(),
|
||||
}),
|
||||
icon_hint: Some("page".into()),
|
||||
|
||||
@@ -40,10 +40,11 @@ pub use kernel::{
|
||||
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
|
||||
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
|
||||
DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp,
|
||||
KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult,
|
||||
KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
||||
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
|
||||
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
|
||||
KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode, KernelDetachEdge,
|
||||
KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree,
|
||||
KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren,
|
||||
KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType,
|
||||
KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind,
|
||||
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
|
||||
@@ -150,6 +150,7 @@ fn tiptap_import_preserves_explicit_block_ids_and_marks() {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("p-1".into()),
|
||||
text_align: None,
|
||||
..TiptapParagraphAttrs::default()
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "Hello".into(),
|
||||
@@ -183,6 +184,7 @@ fn tiptap_import_preserves_explicit_block_ids_and_marks() {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("t-1".into()),
|
||||
text_align: None,
|
||||
..TiptapParagraphAttrs::default()
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "todo".into(),
|
||||
@@ -335,3 +337,41 @@ fn tiptap_table_round_trip_preserves_table_node() {
|
||||
Some(&serde_json::json!("A1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiptap_mindmap_placeholder_round_trip_preserves_mindmap_attrs() {
|
||||
let doc: TiptapNode = serde_json::from_value(serde_json::json!({
|
||||
"type": "doc",
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"blockId": "mind_1",
|
||||
"mnoteBlockType": "mindmap",
|
||||
"mindmapId": "mind_1",
|
||||
"rootNodeId": "root",
|
||||
"projectionVersion": 1
|
||||
}
|
||||
}]
|
||||
}))
|
||||
.expect("mindmap placeholder JSON should parse");
|
||||
|
||||
let imported = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_mindmap", &doc)
|
||||
.expect("mindmap placeholder should import");
|
||||
assert_eq!(imported.blocks[0].block_type, EditorBlockType::Mindmap);
|
||||
assert_eq!(
|
||||
imported.blocks[0].props.extra.get("mindmapId"),
|
||||
Some(&serde_json::json!("mind_1"))
|
||||
);
|
||||
|
||||
let exported =
|
||||
EditorBlockDocumentTiptapBridge::to_tiptap_doc(&imported).expect("mindmap should export");
|
||||
let exported_value = serde_json::to_value(exported).expect("exported doc should serialize");
|
||||
assert_eq!(
|
||||
exported_value.pointer("/content/0/attrs/mnoteBlockType"),
|
||||
Some(&serde_json::json!("mindmap"))
|
||||
);
|
||||
assert_eq!(
|
||||
exported_value.pointer("/content/0/attrs/mindmapId"),
|
||||
Some(&serde_json::json!("mind_1"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use core_protocol::{
|
||||
KernelBlockAssetRelation, KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind,
|
||||
KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn object_identity_separates_index_body_from_mindmap_asset() {
|
||||
let index_identity = KernelObjectIdentity {
|
||||
object_kind: KernelObjectKind::Index,
|
||||
document_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
asset_id: None,
|
||||
};
|
||||
let mindmap_identity = KernelObjectIdentity {
|
||||
object_kind: KernelObjectKind::Mindmap,
|
||||
document_id: Some("doc_1".into()),
|
||||
block_id: Some("block_mindmap_1".into()),
|
||||
asset_id: Some("mind_1".into()),
|
||||
};
|
||||
|
||||
assert_ne!(index_identity, mindmap_identity);
|
||||
|
||||
let index_json = serde_json::to_value(&index_identity).expect("index identity 序列化");
|
||||
let mindmap_json = serde_json::to_value(&mindmap_identity).expect("mindmap identity 序列化");
|
||||
|
||||
assert_eq!(index_json["objectKind"], "index");
|
||||
assert_eq!(index_json["documentId"], "doc_1");
|
||||
assert_eq!(mindmap_json["objectKind"], "mindmap");
|
||||
assert_eq!(mindmap_json["assetId"], "mind_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_meta_can_describe_asset_object_kinds() {
|
||||
let cases = [
|
||||
(
|
||||
KernelObjectKind::Mindmap,
|
||||
KernelProjectionResourceKind::Mindmap,
|
||||
KernelProjectionAssetKind::Mindmap,
|
||||
"mind_1",
|
||||
),
|
||||
(
|
||||
KernelObjectKind::OnlyOffice,
|
||||
KernelProjectionResourceKind::OnlyOffice,
|
||||
KernelProjectionAssetKind::File,
|
||||
"office_1",
|
||||
),
|
||||
(
|
||||
KernelObjectKind::Attachment,
|
||||
KernelProjectionResourceKind::Attachment,
|
||||
KernelProjectionAssetKind::Image,
|
||||
"image_1",
|
||||
),
|
||||
(
|
||||
KernelObjectKind::Code,
|
||||
KernelProjectionResourceKind::Code,
|
||||
KernelProjectionAssetKind::File,
|
||||
"code_1",
|
||||
),
|
||||
];
|
||||
|
||||
for (object_kind, resource_kind, asset_kind, asset_id) in cases {
|
||||
let meta = KernelProjectionResourceMeta {
|
||||
resource_kind: Some(resource_kind),
|
||||
document_id: Some("doc_1".into()),
|
||||
asset_id: Some(asset_id.into()),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
asset_kind: Some(asset_kind.clone()),
|
||||
icon_hint: None,
|
||||
object_identity: Some(KernelObjectIdentity {
|
||||
object_kind,
|
||||
document_id: Some("doc_1".into()),
|
||||
block_id: Some("block_1".into()),
|
||||
asset_id: Some(asset_id.into()),
|
||||
}),
|
||||
block_asset_relation: Some(KernelBlockAssetRelation {
|
||||
document_id: "doc_1".into(),
|
||||
block_id: "block_1".into(),
|
||||
asset_id: asset_id.into(),
|
||||
asset_kind,
|
||||
}),
|
||||
extra: Default::default(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&meta).expect("resource meta 序列化");
|
||||
assert_eq!(json["objectIdentity"]["documentId"], "doc_1");
|
||||
assert_eq!(json["blockAssetRelation"]["blockId"], "block_1");
|
||||
assert_eq!(json["blockAssetRelation"]["assetId"], asset_id);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
|
||||
};
|
||||
@@ -574,17 +576,21 @@ pub async fn save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let mut result = execute_runtime_command_via_convex(
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let mut result = execution.result;
|
||||
if let Value::Object(map) = &mut result {
|
||||
map.insert("executedCommand".into(), json!("page.body.save"));
|
||||
map.insert("canonicalCommand".into(), json!("page.body.save"));
|
||||
map.insert("compatRoute".into(), json!("/api/documents/save"));
|
||||
if let Some(artifact_error) = execution.artifact_error {
|
||||
map.insert("artifactError".into(), json!(artifact_error));
|
||||
}
|
||||
}
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
|
||||
use crate::routes::web_shell::{
|
||||
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
|
||||
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
|
||||
build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html,
|
||||
load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
render_document_title_controller_script, render_editor_island_adapter_script,
|
||||
render_local_file_tree_html, render_local_sidebar_tree_html,
|
||||
};
|
||||
@@ -336,6 +337,17 @@ pub async fn root_entry(
|
||||
active_source_kind.as_deref(),
|
||||
active_root_uri.as_deref(),
|
||||
);
|
||||
let panes_bootstrap_json = build_document_panes_bootstrap_json(
|
||||
&aggregate,
|
||||
&context,
|
||||
active_source_kind.as_deref(),
|
||||
active_root_uri.as_deref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::document::DocumentPage
|
||||
title={title.to_string()}
|
||||
@@ -350,10 +362,12 @@ pub async fn root_entry(
|
||||
let body_extra = format!(
|
||||
r#"<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
||||
{}
|
||||
{}"#,
|
||||
escape_script_json(&snapshot_json),
|
||||
escape_script_json(&bootstrap_json),
|
||||
escape_script_json(&panes_bootstrap_json),
|
||||
render_document_title_controller_script(),
|
||||
render_editor_island_adapter_script(),
|
||||
);
|
||||
@@ -953,6 +967,20 @@ mod tests {
|
||||
legacy_next_base_url: String,
|
||||
enable_legacy_next_compat: bool,
|
||||
convex_url: Option<String>,
|
||||
) -> axum::Router {
|
||||
app_with_query_fixtures(
|
||||
legacy_next_base_url,
|
||||
enable_legacy_next_compat,
|
||||
convex_url,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn app_with_query_fixtures(
|
||||
legacy_next_base_url: String,
|
||||
enable_legacy_next_compat: bool,
|
||||
convex_url: Option<String>,
|
||||
query_fixtures_json: Option<String>,
|
||||
) -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
@@ -967,7 +995,7 @@ mod tests {
|
||||
convex_url,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
query_fixtures_json,
|
||||
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
@@ -1221,6 +1249,55 @@ mod tests {
|
||||
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_active_page_includes_document_panes_bootstrap() {
|
||||
let response = app_with_query_fixtures(
|
||||
"http://127.0.0.1:3100".into(),
|
||||
false,
|
||||
None,
|
||||
Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"updated_at": "2026-04-18T09:30:00Z",
|
||||
"can_edit": true,
|
||||
"word_count": 42,
|
||||
"character_count": 128,
|
||||
"block_count": 1
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/?pageId=doc_1&workspaceId=ws_demo")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("__MNOTE_PAGE_AGGREGATE__"));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(html.contains("mnote.document_panes_bootstrap.v1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_folder_without_debug_tree_route() {
|
||||
let root =
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id,
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex, fetch_query_data_via_convex,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
@@ -35,6 +36,15 @@ pub struct MindmapCommandRequest {
|
||||
pub commands: Vec<Value>,
|
||||
pub projection_revision: Option<u64>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub data: Option<Value>,
|
||||
pub create_only: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_mindmap_data() -> Value {
|
||||
json!({
|
||||
"data": {"text": "中心主题"},
|
||||
"children": [],
|
||||
})
|
||||
}
|
||||
|
||||
fn response_headers() -> HeaderMap {
|
||||
@@ -62,6 +72,41 @@ fn resolve_query_name(params: &MindmapQueryParams) -> &'static str {
|
||||
"mindmap.projection.get"
|
||||
}
|
||||
|
||||
fn read_workspace_id_from_meta(meta: &Value) -> Option<String> {
|
||||
meta.get("workspace_id")
|
||||
.or_else(|| meta.get("workspaceId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
async fn resolve_mindmap_workspace_id(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
explicit_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<Option<String>, WebError> {
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(context, explicit_workspace_id, false)?;
|
||||
if effective_workspace_id.is_some() {
|
||||
return Ok(effective_workspace_id);
|
||||
}
|
||||
|
||||
// 思维导图 runtime 的历史请求体不一定带 workspaceId;
|
||||
// 这里从页面 meta 反查,确保后续 command artifacts 能进入正确 workspace 的实时流。
|
||||
let meta = fetch_documents_meta_via_convex(state.config(), context, None, document_id).await?;
|
||||
Ok(read_workspace_id_from_meta(&meta))
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
pub async fn get_mindmap(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -114,15 +159,79 @@ pub async fn apply_mindmap_command(
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
if body.command_name.as_deref() != Some("mindmap.command.apply") {
|
||||
let command_name = body.command_name.as_deref();
|
||||
if !matches!(
|
||||
command_name,
|
||||
None | Some("mindmaps.put") | Some("mindmap.command.apply")
|
||||
) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mindmap_command_required",
|
||||
"仅支持 mindmap.command.apply",
|
||||
"仅支持 mindmaps.put 或 mindmap.command.apply",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
resolve_mindmap_workspace_id(&state, &context, body.workspace_id.as_deref(), document_id)
|
||||
.await?;
|
||||
|
||||
if command_name != Some("mindmap.command.apply") {
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "mindmaps.put".into(),
|
||||
command_id: format!("mindmap_put_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
page_id: Some(document_id.to_string()),
|
||||
block_id: Some(mindmap_id.to_string()),
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"mindmapId": mindmap_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"data": body.data.unwrap_or_else(default_mindmap_data),
|
||||
"createOnly": body.create_only.unwrap_or(false),
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web mindmap put via kernel projection".into()),
|
||||
refs: vec!["task168-mindmap-put-validator-smoke".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
response_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"commandName": "mindmaps.put",
|
||||
"result": execution.result,
|
||||
"artifacts": execution_artifacts_json(&execution),
|
||||
"artifactError": execution.artifact_error,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let current = fetch_query_data_via_convex(
|
||||
state.config(),
|
||||
&context,
|
||||
@@ -184,7 +293,7 @@ pub async fn apply_mindmap_command(
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let result = execute_runtime_command_via_convex(
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -201,7 +310,96 @@ pub async fn apply_mindmap_command(
|
||||
"applied": applied.applied,
|
||||
"errors": applied.errors,
|
||||
"projectionRevision": body.projection_revision,
|
||||
"result": result,
|
||||
"result": execution.result,
|
||||
"artifacts": execution_artifacts_json(&execution),
|
||||
"artifactError": execution.artifact_error,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{"documents:getMeta":{"id":"doc_1","workspace_id":"ws_demo","title":"页面"},"mindmaps:get":{"data":{"data":{"text":"KMIND","uid":"root"},"children":[]},"revision":1}}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{"mindmaps:put":{"ok":true,"document_id":"doc_1","mindmap_id":"mind_1","updated_at":"2026-05-12T00:00:00Z"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mindmap_put_derives_workspace_and_returns_tree_artifacts() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/mindmap/doc_1/mind_1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"data": {
|
||||
"data": {"text": "KMIND", "uid": "root"},
|
||||
"children": []
|
||||
},
|
||||
"createOnly": true
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
|
||||
assert_eq!(payload["artifacts"]["commandLog"]["workspaceId"], "ws_demo");
|
||||
assert_eq!(payload["artifacts"]["commandLog"]["targetPageId"], "doc_1");
|
||||
assert_eq!(
|
||||
payload["artifacts"]["commandLog"]["targetBlockId"],
|
||||
"mind_1"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["artifacts"]["domainEvent"]["eventType"],
|
||||
"tree.resource.mindmap.put"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["artifacts"]["domainEvent"]["payload"]["streamDelta"],
|
||||
json!({
|
||||
"op": "resync_required",
|
||||
"reason": "mindmap.put",
|
||||
"documentId": "doc_1",
|
||||
"blockId": "mind_1"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::web_shell::{
|
||||
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html,
|
||||
load_workspace_shell_projection,
|
||||
};
|
||||
use crate::ssr::pages::mindmap::MindmapPage;
|
||||
use axum::extract::{Extension, Path};
|
||||
use crate::workspace_shell::render_workspace_shell_sidebar_html;
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
@@ -10,9 +16,92 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
||||
|
||||
pub async fn mindmap_object_shell(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path((doc_id, mindmap_id)): Path<(String, String)>,
|
||||
) -> Result<Response, WebError> {
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let aggregate = build_page_aggregate_snapshot(&state, &context, &doc_id, None, None, None)
|
||||
.await
|
||||
.ok();
|
||||
let workspace_id = aggregate
|
||||
.as_ref()
|
||||
.map(|value| value.identity.workspace_id.clone())
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let title = aggregate
|
||||
.as_ref()
|
||||
.map(|value| value.head.title.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "思维导图".to_string());
|
||||
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) =
|
||||
if let Some(workspace_id) = workspace_id.as_deref() {
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
Some(&doc_id),
|
||||
&default_workspace_name,
|
||||
)
|
||||
.await;
|
||||
let sidebar_tree_html =
|
||||
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_tree_html =
|
||||
load_file_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
);
|
||||
(
|
||||
Some(workspace_name),
|
||||
Some(sidebar_tree_html),
|
||||
Some(workspace_sidebar_html),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
let editor_bootstrap = json!({
|
||||
"documentId": format!("__mindmap_object__:{doc_id}:{mindmap_id}"),
|
||||
"workspaceId": workspace_id
|
||||
.as_ref()
|
||||
.map(|value| format!("__mindmap_object__:{value}")),
|
||||
"title": title.clone(),
|
||||
"content": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"mindmapId": mindmap_id,
|
||||
"mnoteBlockType": "mindmap",
|
||||
"projectionVersion": 1,
|
||||
"rootNodeId": "root",
|
||||
"mnoteMindmapData": serde_json::Value::Null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"readOnly": false,
|
||||
"editable": true,
|
||||
"standaloneObject": {
|
||||
"kind": "mindmap",
|
||||
"documentId": doc_id,
|
||||
"mindmapId": mindmap_id
|
||||
},
|
||||
"revision": serde_json::Value::Null,
|
||||
"conflictDetectionKey": serde_json::Value::Null,
|
||||
"pageOptions": {
|
||||
"pageWidth": "full",
|
||||
"smallText": false,
|
||||
"showHeadingNumbers": false,
|
||||
"fontFamily": "sans"
|
||||
}
|
||||
});
|
||||
let contract = json!({
|
||||
"schema": "mnote.mindmap_shell.v1",
|
||||
"owner": "mnote-web",
|
||||
@@ -35,11 +124,17 @@ pub async fn mindmap_object_shell(
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id
|
||||
});
|
||||
let editor_bootstrap_json =
|
||||
serde_json::to_string(&editor_bootstrap).unwrap_or_else(|_| "null".to_string());
|
||||
let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string());
|
||||
let body_content = crate::ssr::render_view(leptos::view! {
|
||||
<MindmapPage
|
||||
document_id={doc_id.clone()}
|
||||
mindmap_id={mindmap_id.clone()}
|
||||
title={title.clone()}
|
||||
sidebar_tree_html={sidebar_tree_html.unwrap_or_default()}
|
||||
workspace_name={workspace_name.unwrap_or_default()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}
|
||||
/>
|
||||
});
|
||||
let html = format!(
|
||||
@@ -47,19 +142,24 @@ pub async fn mindmap_object_shell(
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>思维导图</title>
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}">
|
||||
{}
|
||||
<script id="__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_MINDMAP_SHELL__" type="application/json">{}</script>
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(&title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&doc_id),
|
||||
escape_html(&mindmap_id),
|
||||
body_content,
|
||||
escape_script_json(&editor_bootstrap_json),
|
||||
escape_script_json(&contract_json),
|
||||
render_mindmap_standalone_bootstrap_script(),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "mindmap");
|
||||
@@ -87,6 +187,80 @@ fn escape_script_json(value: &str) -> String {
|
||||
value.replace("</script", "<\\/script")
|
||||
}
|
||||
|
||||
fn render_mindmap_standalone_bootstrap_script() -> &'static str {
|
||||
r#"<script type="module">
|
||||
(() => {
|
||||
const BOOTSTRAP_ID = '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__';
|
||||
const MOUNT_ID = 'mnote-mindmap-island';
|
||||
|
||||
const parseJsonScript = (id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent || 'null');
|
||||
} catch (error) {
|
||||
console.warn(`mnote mindmap bootstrap JSON 解析失败: ${id}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadRuntime = async () => {
|
||||
if (window.__mnoteLeptosTiptapRuntimePromise) {
|
||||
return window.__mnoteLeptosTiptapRuntimePromise;
|
||||
}
|
||||
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
|
||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
|
||||
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
||||
const manifest = await manifestResponse.json();
|
||||
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
||||
const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`;
|
||||
const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined;
|
||||
const runtime = await import(entryUrl);
|
||||
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
|
||||
throw new Error('island runtime 导出不完整');
|
||||
}
|
||||
await runtime.default(wasmUrl);
|
||||
if (typeof runtime.mount_mindmap_shell === 'function' && typeof runtime.unmount_mindmap_shell === 'function') {
|
||||
window.__MNOTE_MINDMAP_RUST_SHELL__ = {
|
||||
mount: runtime.mount_mindmap_shell,
|
||||
unmount: runtime.unmount_mindmap_shell,
|
||||
};
|
||||
}
|
||||
return runtime;
|
||||
})();
|
||||
return window.__mnoteLeptosTiptapRuntimePromise;
|
||||
};
|
||||
|
||||
const bootstrap = parseJsonScript(BOOTSTRAP_ID);
|
||||
const mountTarget = document.getElementById(MOUNT_ID);
|
||||
if (!bootstrap || !(mountTarget instanceof HTMLElement)) return;
|
||||
|
||||
let mountId = null;
|
||||
let runtimeModule = null;
|
||||
|
||||
const mountStandaloneMindmap = async () => {
|
||||
runtimeModule = await loadRuntime();
|
||||
mountId = runtimeModule.mount(mountTarget, bootstrap);
|
||||
mountTarget.setAttribute('data-runtime-mount-id', String(mountId));
|
||||
};
|
||||
|
||||
void mountStandaloneMindmap().catch((error) => {
|
||||
console.error('mnote standalone mindmap mount failed', error);
|
||||
mountTarget.setAttribute('data-runtime-editor-status', 'error');
|
||||
mountTarget.setAttribute('data-runtime-editor-error', error instanceof Error ? error.message : 'unknown');
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (mountId != null && runtimeModule && typeof runtimeModule.unmount === 'function') {
|
||||
try {
|
||||
runtimeModule.unmount(mountId);
|
||||
} catch (_error) {}
|
||||
}
|
||||
}, { once: true });
|
||||
})();
|
||||
</script>"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
@@ -189,9 +363,21 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("mnote.mindmap_shell.v1"));
|
||||
assert!(html.contains("data-mnote-object-editor=\"mindmap\""));
|
||||
assert!(html.contains(
|
||||
"data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\""
|
||||
));
|
||||
assert!(html.contains("data-leptos-mindmap-island=\"standalone\""));
|
||||
assert!(html.contains("mindmap.simple_mind_map_scene.get"));
|
||||
assert!(html.contains("mindmap.command.apply"));
|
||||
assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
|
||||
assert!(html.contains("mnoteBlockType"));
|
||||
assert!(html.contains("__mindmap_object__:doc_1:mind_1"));
|
||||
assert!(html.contains("\"standaloneObject\""));
|
||||
assert!(html.contains("\"documentId\":\"doc_1\""));
|
||||
assert!(html.contains("\"mindmapId\":\"mind_1\""));
|
||||
assert!(html.contains("runtimeModule.mount(mountTarget, bootstrap)"));
|
||||
assert!(html.contains("/api/leptos-tiptap-runtime/manifest.json"));
|
||||
assert!(!html.contains("react_mindmap_runtime"));
|
||||
assert!(!html.contains("next-app-router"));
|
||||
}
|
||||
|
||||
@@ -57,8 +57,9 @@ pub async fn events(
|
||||
state.polls += 1;
|
||||
sleep(Duration::from_millis(poll_ms)).await;
|
||||
|
||||
let poll_query = live_poll_query(&state.query);
|
||||
let Ok((workspace_id, overview)) =
|
||||
load_stream_overview(state.app_state.config(), &state.context, &state.query)
|
||||
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
@@ -76,7 +77,7 @@ pub async fn events(
|
||||
let Ok(payload) = build_stream_delta_payload(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&state.query,
|
||||
&poll_query,
|
||||
&workspace_id,
|
||||
&overview,
|
||||
change.cursor,
|
||||
@@ -91,18 +92,15 @@ pub async fn events(
|
||||
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
||||
}
|
||||
StreamChangeKind::Resync => {
|
||||
let mut next_query = state.query.clone();
|
||||
next_query.cursor = change.cursor;
|
||||
let Ok(snapshot_payload) = load_stream_snapshot(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&next_query,
|
||||
&poll_query,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
state.query = next_query;
|
||||
state.current_cursor = read_stream_cursor_from_payload(&snapshot_payload);
|
||||
return Some((
|
||||
Ok(stream_event(
|
||||
@@ -157,6 +155,14 @@ struct StreamPollState {
|
||||
initial_emitted: bool,
|
||||
}
|
||||
|
||||
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
|
||||
let mut next = query.clone();
|
||||
// Convex bridgeLogs 的 cursor 是“向更旧记录翻页”,不是 live tail 的起点;
|
||||
// 实时轮询必须始终查最新窗口,再用 current_cursor 在 Rust 侧比较增量。
|
||||
next.cursor = None;
|
||||
next
|
||||
}
|
||||
|
||||
fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||
let event_id = payload
|
||||
.get("revision")
|
||||
@@ -183,6 +189,7 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::routes::stream_support::StreamSnapshotQuery;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
@@ -289,4 +296,20 @@ mod tests {
|
||||
assert!(text.contains("id: "));
|
||||
assert!(text.contains("\"revision\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_poll_query_drops_bridge_pagination_cursor() {
|
||||
let query = StreamSnapshotQuery {
|
||||
workspace_id: Some("ws_demo".into()),
|
||||
cursor: Some(r#"{"createdAt":"2026-05-12T00:00:00Z","id":"clog_1"}"#.into()),
|
||||
poll_ms: Some(250),
|
||||
..StreamSnapshotQuery::default()
|
||||
};
|
||||
|
||||
let live_query = super::live_poll_query(&query);
|
||||
|
||||
assert_eq!(live_query.workspace_id, Some("ws_demo".into()));
|
||||
assert_eq!(live_query.poll_ms, Some(250));
|
||||
assert_eq!(live_query.cursor, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +540,9 @@ pub(crate) fn collect_filetree_render_rows(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
object_identity: resource_meta
|
||||
.and_then(|meta| meta.get("objectIdentity"))
|
||||
.and_then(|value| serde_json::to_string(value).ok()),
|
||||
selected,
|
||||
})
|
||||
})
|
||||
@@ -1686,13 +1689,25 @@ fn build_tree_shell_html(
|
||||
documentId: "",
|
||||
assetId: "",
|
||||
assetKind: "",
|
||||
objectIdentity: null,
|
||||
blockAssetRelation: null,
|
||||
};
|
||||
}
|
||||
const objectIdentity =
|
||||
value?.objectIdentity && typeof value.objectIdentity === "object"
|
||||
? value.objectIdentity
|
||||
: null;
|
||||
const blockAssetRelation =
|
||||
value?.blockAssetRelation && typeof value.blockAssetRelation === "object"
|
||||
? value.blockAssetRelation
|
||||
: null;
|
||||
return {
|
||||
resourceKind: normalizeText(value?.resourceKind),
|
||||
documentId: normalizeText(value?.documentId),
|
||||
assetId: normalizeText(value?.assetId),
|
||||
assetKind: normalizeText(value?.assetKind),
|
||||
objectIdentity,
|
||||
blockAssetRelation,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4690,12 +4705,14 @@ fn build_tree_shell_html(
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -4857,6 +4874,10 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
: "";
|
||||
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
||||
row.dataset.shellMode = "filetree";
|
||||
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
||||
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
|
||||
@@ -5308,12 +5329,14 @@ fn build_tree_shell_html(
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -5382,6 +5405,10 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
: "";
|
||||
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
||||
row.dataset.shellMode = "filetree";
|
||||
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
||||
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
|
||||
|
||||
@@ -288,7 +288,7 @@ pub(crate) fn build_editor_bootstrap_json_with_ids(
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn build_document_panes_bootstrap_json(
|
||||
pub(crate) fn build_document_panes_bootstrap_json(
|
||||
aggregate: &PageAggregate,
|
||||
context: &RequestContext,
|
||||
source_kind: Option<&str>,
|
||||
@@ -815,6 +815,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content };
|
||||
}
|
||||
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
|
||||
if (type === 'mindmap') {
|
||||
const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null;
|
||||
const mindmapId = firstNonEmptyText(
|
||||
block?.props?.mindmapId,
|
||||
block?.props?.mindmap_id,
|
||||
block?.mindmapId,
|
||||
block?.mindmap_id,
|
||||
data?.mindmapId,
|
||||
data?.mindmap_id,
|
||||
data?.id
|
||||
);
|
||||
const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
|
||||
return {
|
||||
type: 'paragraph',
|
||||
attrs: withTextAlign({
|
||||
blockId,
|
||||
mnoteBlockType: 'mindmap',
|
||||
mindmapId,
|
||||
rootNodeId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (type === 'media') {
|
||||
const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src);
|
||||
const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath);
|
||||
@@ -882,6 +904,41 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
content.type === 'doc'
|
||||
);
|
||||
|
||||
const mindmapDomDescriptors = (root) => {
|
||||
if (!(root instanceof HTMLElement)) return [];
|
||||
return Array.from(root.querySelectorAll('[data-testid="mnote-mindmap-editor-root"]'))
|
||||
.flatMap((node) => {
|
||||
if (!(node instanceof HTMLElement)) return [];
|
||||
const mindmapId = typeof node.dataset.mnoteMindmapId === 'string' ? node.dataset.mnoteMindmapId.trim() : '';
|
||||
if (!mindmapId) return [];
|
||||
const rootNodeId = typeof node.dataset.mnoteRootNodeId === 'string' && node.dataset.mnoteRootNodeId.trim()
|
||||
? node.dataset.mnoteRootNodeId.trim()
|
||||
: 'root';
|
||||
return [{ mindmapId, rootNodeId }];
|
||||
});
|
||||
};
|
||||
|
||||
const hydrateMindmapAttrsFromDom = (tiptapDocument, root) => {
|
||||
if (!isTiptapDocument(tiptapDocument) || !Array.isArray(tiptapDocument.content)) return tiptapDocument;
|
||||
const descriptors = mindmapDomDescriptors(root);
|
||||
if (!descriptors.length) return tiptapDocument;
|
||||
let index = 0;
|
||||
for (const node of tiptapDocument.content) {
|
||||
if (node?.type !== 'paragraph' || node?.attrs?.mnoteBlockType !== 'mindmap') continue;
|
||||
const descriptor = descriptors[index];
|
||||
index += 1;
|
||||
if (!descriptor) continue;
|
||||
node.attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {};
|
||||
if (typeof node.attrs.mindmapId !== 'string' || !node.attrs.mindmapId.trim()) {
|
||||
node.attrs.mindmapId = descriptor.mindmapId;
|
||||
}
|
||||
if (typeof node.attrs.rootNodeId !== 'string' || !node.attrs.rootNodeId.trim()) {
|
||||
node.attrs.rootNodeId = descriptor.rootNodeId;
|
||||
}
|
||||
}
|
||||
return tiptapDocument;
|
||||
};
|
||||
|
||||
const toTiptapDocument = (content, fallbackText = '') => {
|
||||
if (isTiptapDocument(content)) return content;
|
||||
const blocks = Array.isArray(content) ? content : Array.isArray(content?.blocks) ? content.blocks : [];
|
||||
@@ -923,9 +980,36 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const raw = typeof node?.attrs?.blockId === 'string' ? node.attrs.blockId.trim() : '';
|
||||
return raw || `block-${index + 1}`;
|
||||
};
|
||||
const mindmapPropsFromAttrs = (attrs, fallbackMindmapId) => {
|
||||
const data = attrs?.data && typeof attrs.data === 'object' ? attrs.data : {};
|
||||
const mindmapId = firstNonEmptyText(
|
||||
attrs?.mindmapId,
|
||||
attrs?.mindmap_id,
|
||||
data?.mindmapId,
|
||||
data?.mindmap_id,
|
||||
data?.id,
|
||||
fallbackMindmapId
|
||||
);
|
||||
const rootNodeId = firstNonEmptyText(attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
|
||||
const projectionVersion = Number(attrs?.projectionVersion ?? attrs?.projection_version);
|
||||
return {
|
||||
mindmapId,
|
||||
rootNodeId,
|
||||
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const tiptapNodeToEditorBlock = (node, index) => {
|
||||
const blockId = blockIdOf(node, index);
|
||||
if (node?.type === 'paragraph' && node?.attrs?.mnoteBlockType === 'mindmap') {
|
||||
return {
|
||||
blockId,
|
||||
blockType: 'mindmap',
|
||||
props: mindmapPropsFromAttrs(node?.attrs, blockId),
|
||||
contentNodes: [],
|
||||
childBlockIds: [],
|
||||
};
|
||||
}
|
||||
if (node?.type === 'paragraph') return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
||||
if (node?.type === 'heading') {
|
||||
const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1));
|
||||
@@ -967,6 +1051,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
? { checked: Boolean(block.props?.checked) }
|
||||
: block.blockType === 'code_block'
|
||||
? { language: block.props?.language || null }
|
||||
: block.blockType === 'mindmap'
|
||||
? mindmapPropsFromAttrs(block.props || {}, block.blockId)
|
||||
: block.blockType === 'image'
|
||||
? { ...(block.props || {}) }
|
||||
: block.blockType === 'toc'
|
||||
@@ -974,7 +1060,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: block.blockType === 'table'
|
||||
? { ...(block.props || {}) }
|
||||
: undefined,
|
||||
content: Array.isArray(block.contentNodes)
|
||||
content: block.blockType === 'mindmap'
|
||||
? ''
|
||||
: Array.isArray(block.contentNodes)
|
||||
? block.contentNodes.map((node) => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const text = typeof node.text === 'string' ? node.text : '';
|
||||
@@ -1060,6 +1148,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const paneViewRegistry = new Map();
|
||||
let nextViewId = 1;
|
||||
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
|
||||
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
|
||||
const SESSION_RELEASE_DELAY_MS = 1200;
|
||||
|
||||
const parseLocalFolderEventPayload = (event) => {
|
||||
@@ -1316,6 +1405,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const persistSession = async (session) => {
|
||||
if (session.readOnly || session.saving || session.hasExternalConflict) return;
|
||||
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
|
||||
if (hydrateView) {
|
||||
hydrateMindmapAttrsFromDom(session.currentTiptapDocument, hydrateView.runtimeDescriptor.root);
|
||||
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
|
||||
}
|
||||
const serialized = session.currentSerialized;
|
||||
if (!session.dirty && serialized === session.lastPersistedSerialized) {
|
||||
setSessionStatus(session, 'saved');
|
||||
@@ -1389,16 +1483,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSessionExternalRefresh = (session) => {
|
||||
const scheduleSessionExternalRefresh = (session, source) => {
|
||||
if (session.externalRefreshTimer) return;
|
||||
session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change';
|
||||
session.externalRefreshTimer = window.setTimeout(() => {
|
||||
const refreshSource = session.externalRefreshSource || 'mnote-web-external-change';
|
||||
session.externalRefreshSource = '';
|
||||
session.externalRefreshTimer = 0;
|
||||
void refreshSessionFromExternalFileChange(session);
|
||||
void refreshSessionFromExternalChange(session, refreshSource);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const refreshSessionFromExternalFileChange = async (session) => {
|
||||
if (session.sourceKind !== 'local_folder' || !session.rootUri || document.hidden) return;
|
||||
const refreshSessionFromExternalChange = async (session, source) => {
|
||||
if (document.hidden) return;
|
||||
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
|
||||
try {
|
||||
const response = await fetch(pageAggregateUrl({
|
||||
documentId: session.documentId,
|
||||
@@ -1439,14 +1537,19 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
session.hasExternalConflict = false;
|
||||
session.lastUserInputAt = 0;
|
||||
sessionViews(session).forEach((view) => {
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, 'mnote-web-local-folder-watch');
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-external-change');
|
||||
});
|
||||
setSessionStatus(session, 'synced-external-change');
|
||||
} catch (error) {
|
||||
console.warn('mnote local folder 外部更新检测失败', error);
|
||||
console.warn('mnote 页面外部更新检测失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSessionFromExternalFileChange = async (session) => {
|
||||
if (session.sourceKind !== 'local_folder') return;
|
||||
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
|
||||
};
|
||||
|
||||
const ensureLocalFolderEventChannel = (session) => {
|
||||
if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') {
|
||||
return;
|
||||
@@ -1473,7 +1576,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
markSessionExternalConflict(targetSession, externalConflictMessage);
|
||||
return;
|
||||
}
|
||||
scheduleSessionExternalRefresh(targetSession);
|
||||
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
|
||||
});
|
||||
});
|
||||
eventSource.onerror = () => {
|
||||
@@ -1485,6 +1588,164 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
session.localFolderChannel = channel;
|
||||
};
|
||||
|
||||
const readTreePayloadData = (payload) => (
|
||||
payload && typeof payload === 'object'
|
||||
? (payload.data || payload.delta || payload)
|
||||
: null
|
||||
);
|
||||
|
||||
const readTreePayloadOverview = (payload) => (
|
||||
payload && typeof payload === 'object' && payload.overview && typeof payload.overview === 'object'
|
||||
? payload.overview
|
||||
: null
|
||||
);
|
||||
|
||||
const readTreePayloadCursor = (payload) => {
|
||||
const raw = String(payload?.cursor || payload?.revision || '').trim();
|
||||
if (!raw) return { id: '', createdAt: '', raw: '' };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return {
|
||||
id: String(parsed.id || parsed.commandId || parsed.command_id || '').trim(),
|
||||
createdAt: String(parsed.createdAt || parsed.created_at || '').trim(),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
return { id: raw, createdAt: '', raw };
|
||||
};
|
||||
|
||||
const treeRecordMatchesPayloadCursor = (record, payload) => {
|
||||
if (!record || typeof record !== 'object') return false;
|
||||
const cursor = readTreePayloadCursor(payload);
|
||||
if (!cursor.id && !cursor.createdAt && !cursor.raw) return false;
|
||||
const ids = [
|
||||
record.id,
|
||||
record._id,
|
||||
record.command_log_id,
|
||||
record.commandLogId,
|
||||
record.domain_event_id,
|
||||
record.domainEventId,
|
||||
record.command_id,
|
||||
record.commandId,
|
||||
].map((value) => String(value || '').trim()).filter(Boolean);
|
||||
if (cursor.id && ids.includes(cursor.id)) return true;
|
||||
const createdAt = String(record.created_at || record.createdAt || '').trim();
|
||||
return Boolean(cursor.createdAt && createdAt && cursor.createdAt === createdAt);
|
||||
};
|
||||
|
||||
const treeRecordTargetsDocument = (record, documentId) => {
|
||||
if (!record || typeof record !== 'object' || !documentId) return false;
|
||||
const targetPageId = String(record.target_page_id || record.targetPageId || '').trim();
|
||||
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
|
||||
if (targetPageId === documentId || aggregateId === documentId) return true;
|
||||
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
|
||||
if (!payload) return false;
|
||||
const streamDelta = payload.streamDelta || payload.stream_delta || null;
|
||||
const deltaDocumentId = streamDelta && typeof streamDelta === 'object'
|
||||
? String(streamDelta.documentId || streamDelta.pageId || streamDelta.document_id || streamDelta.page_id || '').trim()
|
||||
: '';
|
||||
return deltaDocumentId === documentId;
|
||||
};
|
||||
|
||||
const collectMindmapIdsFromTreeRecord = (record, documentId, out) => {
|
||||
if (!record || typeof record !== 'object' || !documentId) return;
|
||||
if (!treeRecordTargetsDocument(record, documentId)) return;
|
||||
const targetBlockId = String(record.target_block_id || record.targetBlockId || '').trim();
|
||||
if (targetBlockId) out.add(targetBlockId);
|
||||
const aggregateType = String(record.aggregate_type || record.aggregateType || '').trim();
|
||||
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
|
||||
if (aggregateType === 'block' && aggregateId) out.add(aggregateId);
|
||||
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
|
||||
const streamDelta = payload && typeof payload === 'object' ? (payload.streamDelta || payload.stream_delta || null) : null;
|
||||
const blockId = streamDelta && typeof streamDelta === 'object'
|
||||
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
|
||||
: '';
|
||||
if (blockId) out.add(blockId);
|
||||
};
|
||||
|
||||
const collectMindmapIdsFromTreePayload = (payload, session) => {
|
||||
const ids = new Set();
|
||||
if (!payload || typeof payload !== 'object' || !session?.documentId) return [];
|
||||
const kind = String(payload.kind || '').trim();
|
||||
const data = readTreePayloadData(payload);
|
||||
if (kind === 'delta' && data && typeof data === 'object') {
|
||||
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
|
||||
if (!documentId || documentId === session.documentId) {
|
||||
const blockId = String(data.blockId || data.block_id || '').trim();
|
||||
if (blockId) ids.add(blockId);
|
||||
const streamDelta = data.streamDelta || data.stream_delta || null;
|
||||
const streamBlockId = streamDelta && typeof streamDelta === 'object'
|
||||
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
|
||||
: '';
|
||||
if (streamBlockId) ids.add(streamBlockId);
|
||||
}
|
||||
}
|
||||
if (kind === 'resync') {
|
||||
const overview = readTreePayloadOverview(payload);
|
||||
if (overview) {
|
||||
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
|
||||
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
|
||||
commandLogs
|
||||
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
|
||||
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
|
||||
domainEvents
|
||||
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
|
||||
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
|
||||
}
|
||||
}
|
||||
return Array.from(ids);
|
||||
};
|
||||
|
||||
const refreshMindmapRuntimesFromTreePayload = (payload, session) => {
|
||||
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
||||
collectMindmapIdsFromTreePayload(payload, session).forEach((mindmapId) => {
|
||||
const bridge = registry[mindmapId];
|
||||
if (bridge && typeof bridge.refreshProjection === 'function') {
|
||||
void bridge.refreshProjection('mnote-web-tree-live');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const treePayloadTargetsDocument = (payload, session) => {
|
||||
if (!payload || typeof payload !== 'object' || !session?.documentId) return false;
|
||||
if (payload.workspaceId && session.workspaceId && String(payload.workspaceId) !== String(session.workspaceId)) return false;
|
||||
const data = readTreePayloadData(payload);
|
||||
if (data && typeof data === 'object') {
|
||||
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
|
||||
if (documentId === session.documentId) return true;
|
||||
const documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : [];
|
||||
if (documents.some((item) => String(item?.id || item?.documentId || '').trim() === session.documentId)) return true;
|
||||
}
|
||||
const overview = readTreePayloadOverview(payload);
|
||||
if (!overview) return false;
|
||||
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
|
||||
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
|
||||
return commandLogs.some((record) => treeRecordTargetsDocument(record, session.documentId))
|
||||
|| domainEvents.some((record) => treeRecordTargetsDocument(record, session.documentId));
|
||||
};
|
||||
|
||||
const handleTreeExternalChange = (event) => {
|
||||
const payload = event?.detail?.payload || event?.detail || null;
|
||||
if (!payload) return;
|
||||
Array.from(documentSessionRegistry.values()).forEach((session) => {
|
||||
if (session.sourceKind === 'local_folder') return;
|
||||
if (!treePayloadTargetsDocument(payload, session)) return;
|
||||
refreshMindmapRuntimesFromTreePayload(payload, session);
|
||||
session.lastExternalChangeSignalAt = Date.now();
|
||||
session.externalChangePending = true;
|
||||
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
markSessionExternalConflict(session, treeExternalConflictMessage);
|
||||
return;
|
||||
}
|
||||
scheduleSessionExternalRefresh(session, 'mnote-web-tree-live');
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('tree:delta', handleTreeExternalChange);
|
||||
window.addEventListener('tree:resync', handleTreeExternalChange);
|
||||
|
||||
const createDocumentSession = (runtimeDescriptor) => {
|
||||
const pageBody = runtimeDescriptor.aggregate.body || {};
|
||||
const permissions = runtimeDescriptor.aggregate.head?.permissions || {};
|
||||
@@ -1513,6 +1774,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
saving: false,
|
||||
hasExternalConflict: false,
|
||||
externalChangePending: false,
|
||||
externalRefreshSource: '',
|
||||
lastExternalChangeSignalAt: 0,
|
||||
lastUserInputAt: 0,
|
||||
status: 'booting',
|
||||
@@ -1714,10 +1976,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const pendingExternalChange = session.sourceKind === 'local_folder' && session.externalChangePending;
|
||||
const recentExternalChange = sessionHasRecentExternalSignal(session);
|
||||
const recentLocalInput = sessionHasRecentLocalInput(session);
|
||||
const tiptapDocument = toTiptapDocument(
|
||||
const tiptapDocument = hydrateMindmapAttrsFromDom(toTiptapDocument(
|
||||
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
|
||||
currentEditorText(view),
|
||||
);
|
||||
), view.runtimeDescriptor.root);
|
||||
const serialized = JSON.stringify(tiptapDocument);
|
||||
if (view.suppressedSerialized && view.suppressedSerialized === serialized) {
|
||||
view.suppressedSerialized = null;
|
||||
@@ -2614,6 +2876,13 @@ mod tests {
|
||||
assert!(html.contains("btn.getAttribute('data-page-openable') === 'false'"));
|
||||
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
|
||||
assert!(html.contains("refreshSessionFromExternalFileChange"));
|
||||
assert!(html.contains("refreshSessionFromExternalChange"));
|
||||
assert!(html.contains("treeExternalConflictMessage"));
|
||||
assert!(html.contains("tree:delta"));
|
||||
assert!(html.contains("tree:resync"));
|
||||
assert!(html.contains("mnote-web-tree-live"));
|
||||
assert!(html.contains("refreshMindmapRuntimesFromTreePayload"));
|
||||
assert!(html.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__"));
|
||||
assert!(html.contains("/api/local-folder/events"));
|
||||
assert!(html.contains("new EventSource(url.toString())"));
|
||||
assert!(html.contains("localFolderEventRegistry"));
|
||||
@@ -2714,6 +2983,14 @@ mod tests {
|
||||
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
|
||||
assert!(html.contains("styles.link = href"));
|
||||
assert!(html.contains("contentNodes.map((node) => {"));
|
||||
assert!(html.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
|
||||
assert!(html.contains("blockType: 'mindmap'"));
|
||||
assert!(html.contains("props: mindmapPropsFromAttrs(node?.attrs, blockId)"));
|
||||
assert!(html.contains("mindmapPropsFromAttrs(block.props || {}, block.blockId)"));
|
||||
assert!(html.contains("mnoteBlockType: 'mindmap'"));
|
||||
assert!(html.contains("block.blockType === 'mindmap'"));
|
||||
assert!(html.contains("content: block.blockType === 'mindmap'"));
|
||||
assert!(html.contains("? ''"));
|
||||
assert!(!html.contains(
|
||||
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
|
||||
));
|
||||
|
||||
@@ -962,6 +962,25 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return '';
|
||||
}
|
||||
|
||||
function fileObjectIdentity(item) {
|
||||
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
|
||||
if (meta.objectIdentity && typeof meta.objectIdentity === 'object') return meta.objectIdentity;
|
||||
var rowKind = String(item && item.rowKind || '');
|
||||
var documentId = fileDocumentId(item) || null;
|
||||
var assetId = fileAssetId(item) || null;
|
||||
var iconKind = iconKindOf(item);
|
||||
var objectKind = rowKind === 'document' ? 'page' : rowKind === 'index' ? 'index' : iconKind === 'mindmap' ? 'mindmap' : 'attachment';
|
||||
return { objectKind: objectKind, documentId: documentId, blockId: null, assetId: assetId };
|
||||
}
|
||||
|
||||
function objectIdentityAttr(identity) {
|
||||
try {
|
||||
return JSON.stringify(identity || {});
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function iconKindOf(item) {
|
||||
return String(item && (item.iconHint || item.rowKind || 'file') || 'file').trim() || 'file';
|
||||
}
|
||||
@@ -976,6 +995,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var parent = parentIdOf(item);
|
||||
var documentId = fileDocumentId(item);
|
||||
var assetId = fileAssetId(item);
|
||||
var objectIdentity = fileObjectIdentity(item);
|
||||
var children = grouped.get(nodeId) || [];
|
||||
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
|
||||
var expanded = expandable && item.expandedByDefault !== false;
|
||||
@@ -990,7 +1010,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var childHtml = expandable
|
||||
? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderFileRows(nodeId, grouped, activeId) + '</ul>'
|
||||
: '';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKindOf(item)) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKindOf(item)) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -1066,11 +1086,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (['doc', 'docx', 'odt', 'rtf'].indexOf(ext) >= 0) return ext;
|
||||
if (['ppt', 'pptx', 'odp'].indexOf(ext) >= 0) return ext;
|
||||
if (['xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return ext;
|
||||
if (ext === 'pdf') return ext;
|
||||
if (isNonOfficeAttachmentName(name, ext)) return '';
|
||||
if (mt.indexOf('wordprocessingml') >= 0) return 'docx';
|
||||
if (mt.indexOf('presentationml') >= 0) return 'pptx';
|
||||
if (mt.indexOf('spreadsheetml') >= 0) return 'xlsx';
|
||||
if (mt.indexOf('pdf') >= 0) return 'pdf';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1098,6 +1117,32 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return '/onlyoffice?' + params.toString();
|
||||
}
|
||||
|
||||
function buildMindmapOpenPath(documentId, assetId) {
|
||||
var doc = String(documentId || '').trim();
|
||||
var map = String(assetId || '').trim();
|
||||
if (!doc || !map) return '';
|
||||
return '/mindmap/' + encodeURIComponent(doc) + '/' + encodeURIComponent(map);
|
||||
}
|
||||
|
||||
function isMindmapAssetDetail(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
var assetType = String(detail && detail.assetType || '').trim();
|
||||
if (assetType === 'mindmap') return true;
|
||||
return assetId.indexOf('mindmap_') === 0 || assetId.indexOf('mindmap-') === 0;
|
||||
}
|
||||
|
||||
function readFileTreeObjectIdentity(row) {
|
||||
if (!row) return null;
|
||||
var raw = row.getAttribute('data-object-identity') || '';
|
||||
if (!raw) return null;
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrentOnlyOfficeUserId() {
|
||||
try {
|
||||
var response = await fetch('/api/auth/whoami', {
|
||||
@@ -1115,6 +1160,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
async function openConvexAssetFromFileTree(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
if (!assetId) return;
|
||||
var documentId = String(detail && detail.documentId || '').trim();
|
||||
if (isMindmapAssetDetail(detail) && documentId) {
|
||||
var mindmapPath = buildMindmapOpenPath(documentId, assetId);
|
||||
if (mindmapPath) {
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-object-shell');
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
|
||||
window.location.assign(mindmapPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
|
||||
method: 'GET',
|
||||
@@ -1142,6 +1197,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}), '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) {
|
||||
await openCodeEditorAttachment({
|
||||
href: fileUrl,
|
||||
fileUrl: fileUrl,
|
||||
fileName: fileName,
|
||||
assetId: assetId,
|
||||
documentId: String(asset.document_id || detail.documentId || '').trim(),
|
||||
fileSize: uploadedFileSize(asset)
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.open(fileUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
window.alert(error && error.message ? error.message : '打开附件失败');
|
||||
@@ -1243,6 +1309,67 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function isNonOfficeAttachmentName(name, ext) {
|
||||
var codeFileNames = [
|
||||
'.dockerignore', '.editorconfig', '.env', '.eslintrc', '.gitattributes', '.gitignore', '.npmrc', '.prettierrc',
|
||||
'dockerfile', 'makefile', 'cmakelists.txt', 'gemfile', 'rakefile', 'procfile'
|
||||
];
|
||||
return [
|
||||
'pdf', 'toml', 'json', 'yaml', 'yml', 'md', 'markdown', 'txt', 'ini', 'env', 'xml', 'html', 'htm', 'css', 'scss',
|
||||
'less', 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'rs', 'go', 'java', 'c', 'cpp', 'h', 'hpp', 'cs',
|
||||
'php', 'rb', 'sh', 'bash', 'zsh', 'sql', 'lock', 'log', 'vue', 'svelte', 'astro', 'jsonc', 'json5',
|
||||
'mts', 'cts', 'lua', 'dart', 'kt', 'kts', 'swift', 'scala', 'gradle', 'groovy', 'clj',
|
||||
'ex', 'exs', 'erl', 'hrl', 'fs', 'fsx', 'r', 'jl', 'm', 'mm', 'pl', 'pm', 'ps1', 'bat', 'cmd',
|
||||
'psm1', 'psd1', 'dockerfile', 'containerfile', 'proto', 'graphql', 'gql', 'prisma', 'tf', 'tfvars',
|
||||
'hcl', 'nix', 'cmake', 'bazel', 'bzl', 'properties', 'conf', 'cfg', 'config', 'service', 'desktop',
|
||||
'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'prettierrc', 'eslintrc'
|
||||
].indexOf(ext) >= 0 || codeFileNames.indexOf(name) >= 0;
|
||||
}
|
||||
|
||||
function attachmentExtensionFromFileName(fileName) {
|
||||
var name = String(fileName || '').trim().toLowerCase();
|
||||
return name.indexOf('.') >= 0 ? name.split('.').pop() : '';
|
||||
}
|
||||
|
||||
function isPdfAttachmentFileName(fileName) {
|
||||
return attachmentExtensionFromFileName(fileName) === 'pdf';
|
||||
}
|
||||
|
||||
function isCodeAttachmentFileName(fileName) {
|
||||
var name = String(fileName || '').trim().toLowerCase();
|
||||
var ext = attachmentExtensionFromFileName(name);
|
||||
return ext !== 'pdf' && isNonOfficeAttachmentName(name, ext);
|
||||
}
|
||||
|
||||
function inferCodeAttachmentLanguage(fileName) {
|
||||
var name = String(fileName || '').trim().toLowerCase();
|
||||
var ext = attachmentExtensionFromFileName(name);
|
||||
var byName = {
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'cmakelists.txt': 'cmake',
|
||||
'.gitignore': 'gitignore',
|
||||
'.gitattributes': 'gitattributes',
|
||||
'.editorconfig': 'ini',
|
||||
'.env': 'dotenv'
|
||||
};
|
||||
if (byName[name]) return byName[name];
|
||||
var byExt = {
|
||||
bash: 'bash', bat: 'batch', c: 'c', cjs: 'javascript', cmd: 'batch', conf: 'text', cpp: 'cpp',
|
||||
cs: 'csharp', css: 'css', cts: 'typescript', dart: 'dart', dockerfile: 'dockerfile', env: 'dotenv',
|
||||
go: 'go', gql: 'graphql', gradle: 'groovy', graphql: 'graphql', h: 'c', hcl: 'hcl', hpp: 'cpp',
|
||||
htm: 'html', html: 'html', ini: 'ini', java: 'java', js: 'javascript', json: 'json', json5: 'json',
|
||||
jsonc: 'jsonc', jsx: 'javascript', kt: 'kotlin', kts: 'kotlin', less: 'less', log: 'text',
|
||||
lua: 'lua', m: 'objective-c', markdown: 'markdown', md: 'markdown', mjs: 'javascript',
|
||||
mts: 'typescript', nix: 'nix', php: 'php', pl: 'perl', pm: 'perl', prisma: 'prisma',
|
||||
proto: 'protobuf', ps1: 'powershell', py: 'python', r: 'r', rb: 'ruby', rs: 'rust',
|
||||
scss: 'scss', sh: 'bash', sql: 'sql', svelte: 'svelte', swift: 'swift', tf: 'terraform',
|
||||
tfvars: 'terraform', toml: 'toml', ts: 'typescript', tsx: 'typescript', txt: 'text',
|
||||
vue: 'vue', xml: 'xml', yaml: 'yaml', yml: 'yaml', zsh: 'bash'
|
||||
};
|
||||
return byExt[ext] || 'text';
|
||||
}
|
||||
|
||||
function attachmentClassForFileName(fileName) {
|
||||
var name = String(fileName || '').trim().toLowerCase();
|
||||
var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : '';
|
||||
@@ -1250,6 +1377,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (['ppt', 'pptx', 'odp'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-ppt';
|
||||
if (['xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-sheet';
|
||||
if (ext === 'pdf') return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-pdf';
|
||||
if (isNonOfficeAttachmentName(name, ext)) {
|
||||
return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-code';
|
||||
}
|
||||
return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-file';
|
||||
}
|
||||
|
||||
@@ -3050,9 +3180,99 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function openEditorAttachmentDetail(detail) {
|
||||
if (!detail || !detail.href) return;
|
||||
if (isPdfAttachmentFileName(detail.fileName)) {
|
||||
void openPdfEditorAttachment(detail);
|
||||
return;
|
||||
}
|
||||
if (isCodeAttachmentFileName(detail.fileName)) {
|
||||
void openCodeEditorAttachment(detail);
|
||||
return;
|
||||
}
|
||||
window.open(detail.href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
async function resolveEditorAttachmentUrl(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
if (assetId) {
|
||||
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload && payload.error ? payload.error : '生成签名链接失败');
|
||||
}
|
||||
var signedUrl = String(payload && payload.signedUrl || '').trim();
|
||||
if (!signedUrl) throw new Error('附件链接不可用');
|
||||
return {
|
||||
url: signedUrl,
|
||||
asset: payload.asset && typeof payload.asset === 'object' ? payload.asset : {}
|
||||
};
|
||||
}
|
||||
var url = String(detail && (detail.fileUrl || detail.href) || '').trim();
|
||||
if (!url) throw new Error('附件链接不可用');
|
||||
return { url: url, asset: {} };
|
||||
}
|
||||
|
||||
async function openPdfEditorAttachment(detail) {
|
||||
try {
|
||||
var resolved = await resolveEditorAttachmentUrl(detail);
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
window.alert(error && error.message ? error.message : '打开 PDF 失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function openCodeEditorAttachment(detail) {
|
||||
var resolved = null;
|
||||
try {
|
||||
resolved = await resolveEditorAttachmentUrl(detail);
|
||||
var size = Number(resolved.asset && (resolved.asset.file_size || resolved.asset.fileSize) || 0);
|
||||
if (Number.isFinite(size) && size > 1024 * 1024) {
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var response = await fetch(resolved.url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!response.ok) throw new Error('读取附件内容失败');
|
||||
var text = await response.text();
|
||||
if (text.length > 1024 * 1024) {
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var editorRoot = document.querySelector('.editor-surface .ProseMirror');
|
||||
var editor = editorRoot && editorRoot.editor;
|
||||
if (!editor || !editor.chain) {
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var title = String(detail.fileName || resolved.asset.file_name || '附件').trim() || '附件';
|
||||
var language = inferCodeAttachmentLanguage(title);
|
||||
editor.chain().focus().insertContent([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: title }]
|
||||
},
|
||||
{
|
||||
type: 'codeBlock',
|
||||
attrs: { language: language },
|
||||
content: text ? [{ type: 'text', text: text.replace(/\r\n?/g, '\n') }] : []
|
||||
}
|
||||
]).run();
|
||||
} catch (error) {
|
||||
console.warn('[mnote attachment] open code attachment failed', error);
|
||||
if (resolved && resolved.url) {
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
window.alert(error && error.message ? error.message : '打开代码附件失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditorAttachmentDownload(detail) {
|
||||
if (!detail) return;
|
||||
if (detail.assetId) {
|
||||
@@ -3330,6 +3550,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var rowKind = fileRow.getAttribute('data-row-kind') || '';
|
||||
var documentId = fileRow.getAttribute('data-document-id') || fileRow.getAttribute('data-doc-id') || '';
|
||||
var assetId = fileRow.getAttribute('data-asset-id') || '';
|
||||
var assetType = '';
|
||||
var kindBadge = fileRow.querySelector('.tree-kind-badge');
|
||||
if (kindBadge instanceof HTMLElement) {
|
||||
assetType = kindBadge.getAttribute('data-kind') || '';
|
||||
}
|
||||
if (fileAction === 'toggle') {
|
||||
e.preventDefault();
|
||||
toggleChildren(fileRow, fileBtn);
|
||||
@@ -3356,14 +3581,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
e.preventDefault();
|
||||
selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey });
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
|
||||
var objectIdentity = readFileTreeObjectIdentity(fileRow);
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) });
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) {
|
||||
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree' });
|
||||
} else if (assetId) {
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId });
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3988,6 +4214,17 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.asset.open"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("openConvexAssetFromFileTree"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("buildMindmapOpenPath"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("isMindmapAssetDetail"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("openMindmapAssetInDocumentShell"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-mindmap-asset-open-mode"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mindmap-object-shell"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("window.location.assign(mindmapPath)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("assetType: assetType || null"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-object-identity"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("readFileTreeObjectIdentity"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("objectIdentity: objectIdentity"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("workspaceId: resolveWorkspaceId(fileRow)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/media/sign?assetId="));
|
||||
assert!(SIDEBAR_TREE_JS.contains("fetchCurrentOnlyOfficeUserId"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami"));
|
||||
@@ -4031,6 +4268,40 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("isNonOfficeAttachmentName(name, ext)"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("if (ext === 'pdf') return ext;"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("mt.indexOf('pdf') >= 0"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-uploaded-attachment-pdf"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-uploaded-attachment-code"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'toml'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'json'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'yaml'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'md'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'vue'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'svelte'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'proto'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'dockerfile'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'.gitignore'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_opens_pdf_and_code_assets_with_builtin_tools() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function openPdfEditorAttachment"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function openCodeEditorAttachment"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function resolveEditorAttachmentUrl"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("type: 'codeBlock'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("attrs: { language: language }"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("inferCodeAttachmentLanguage"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (isPdfAttachmentFileName(detail.fileName))"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (isCodeAttachmentFileName(detail.fileName))"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("if (isCodeAttachmentFileName(fileName) && String(asset.document_id"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("await openCodeEditorAttachment({"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
|
||||
|
||||
@@ -18,17 +18,37 @@ pub fn MindmapPage(
|
||||
document_id: String,
|
||||
/// 思维导图 ID
|
||||
mindmap_id: String,
|
||||
/// 页面标题
|
||||
title: String,
|
||||
/// 侧栏页面树 HTML
|
||||
#[prop(optional)]
|
||||
sidebar_tree_html: Option<String>,
|
||||
/// 工作区名称
|
||||
#[prop(optional)]
|
||||
workspace_name: Option<String>,
|
||||
/// workspace shell 侧栏 sections HTML
|
||||
#[prop(optional)]
|
||||
workspace_sidebar_html: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let object_identity = format!("resource:mindmap:{document_id}:{mindmap_id}");
|
||||
view! {
|
||||
<PageLayout current_nav="documents">
|
||||
<PageLayout
|
||||
current_nav="documents"
|
||||
sidebar_tree_html={sidebar_tree_html.unwrap_or_default()}
|
||||
workspace_name={workspace_name.unwrap_or_default()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}
|
||||
topbar_title={title.clone()}
|
||||
>
|
||||
<main
|
||||
id="mnote-mindmap-shell"
|
||||
data-object-shell="mindmap"
|
||||
data-mnote-object-editor="mindmap"
|
||||
data-mnote-object-identity={object_identity}
|
||||
data-document-id={document_id}
|
||||
data-mindmap-id={mindmap_id}
|
||||
>
|
||||
<header>
|
||||
<h1>{"思维导图"}</h1>
|
||||
<h1>{title}</h1>
|
||||
</header>
|
||||
<section
|
||||
id="mnote-mindmap-island"
|
||||
|
||||
@@ -8,6 +8,7 @@ use bridge_runtime::{
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
|
||||
@@ -19,6 +20,32 @@ const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
|
||||
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
|
||||
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
|
||||
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
||||
static CONVEX_HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
|
||||
fn convex_http_client(context: &RequestContext) -> Result<&'static reqwest::Client, WebError> {
|
||||
if let Some(client) = CONVEX_HTTP_CLIENT.get() {
|
||||
return Ok(client);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.pool_max_idle_per_host(16)
|
||||
.pool_idle_timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
let _ = CONVEX_HTTP_CLIENT.set(client);
|
||||
CONVEX_HTTP_CLIENT.get().ok_or_else(|| {
|
||||
WebError::internal("Convex HTTP 客户端初始化失败")
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})
|
||||
}
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
@@ -232,15 +259,7 @@ pub async fn execute_convex_query_plan(
|
||||
"args": plan.args_json,
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
let client = convex_http_client(context)?;
|
||||
|
||||
let mut request = client
|
||||
.post(format!("{}/api/query", convex_url(config, context)?))
|
||||
@@ -357,6 +376,18 @@ pub async fn execute_convex_query_by_name(
|
||||
|
||||
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
let mut args = plan.args_json.clone();
|
||||
if matches!(plan.command_name.as_str(), "mindmaps.put")
|
||||
|| matches!(plan.function_name.as_str(), "mindmaps:put")
|
||||
{
|
||||
if let Value::Object(map) = &mut args {
|
||||
// Rust plan 保留 tree domain event / stream hint 作为正式契约;
|
||||
// Convex mindmaps.put legacy validator 仍只接收真实写入字段。
|
||||
map.remove("streamDeltaHint");
|
||||
map.remove("domainEventHint");
|
||||
map.remove("domainEventPlan");
|
||||
map.remove("domainEventPlans");
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
"documents.save" | "page.body.save"
|
||||
@@ -414,15 +445,7 @@ pub async fn execute_convex_command_plan(
|
||||
"args": [convex_command_args_for_plan(plan)],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
let client = convex_http_client(context)?;
|
||||
|
||||
let mut request = client
|
||||
.post(format!("{}/api/mutation", convex_url(config, context)?))
|
||||
@@ -550,15 +573,7 @@ pub async fn execute_convex_mutation_by_name(
|
||||
"args": [args],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
let client = convex_http_client(context)?;
|
||||
|
||||
let mut request = client
|
||||
.post(format!("{}/api/mutation", convex_url(config, context)?))
|
||||
@@ -847,6 +862,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_strips_mindmap_put_bridge_artifacts_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "mindmaps.put".into(),
|
||||
command_id: "cmd_mindmap_put_1".into(),
|
||||
function_name: "mindmaps:put".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
request_id: "req_1".into(),
|
||||
trace_id: "trace_1".into(),
|
||||
actor_id: "actor_1".into(),
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"docId": "doc_1",
|
||||
"mindmapId": "mind_1",
|
||||
"data": {"data": {"text": "KMIND"}, "children": []},
|
||||
"createOnly": false,
|
||||
"streamDeltaHint": {"family": "tree"},
|
||||
"domainEventHint": {"eventType": "tree.resource.mindmap.put"},
|
||||
"domainEventPlan": {"eventType": "tree.resource.mindmap.put"},
|
||||
"domainEventPlans": [{"eventType": "tree.resource.mindmap.put"}],
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
json!({
|
||||
"docId": "doc_1",
|
||||
"mindmapId": "mind_1",
|
||||
"data": {"data": {"text": "KMIND"}, "children": []},
|
||||
"createOnly": false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct FileTreeRenderRow {
|
||||
pub icon_kind: String,
|
||||
pub document_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
pub object_identity: Option<String>,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ fn render_filetree_row(
|
||||
String::new()
|
||||
};
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-asset-id="{asset_id}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
@@ -93,6 +94,7 @@ fn render_filetree_row(
|
||||
parent_attr = parent_attr,
|
||||
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
|
||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
||||
selected = row.selected,
|
||||
toggle_html = toggle_html,
|
||||
icon_kind = escape_html(&row.icon_kind),
|
||||
@@ -174,6 +176,9 @@ mod tests {
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
object_identity: Some(
|
||||
r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
|
||||
),
|
||||
selected: true,
|
||||
},
|
||||
FileTreeRenderRow {
|
||||
@@ -188,6 +193,9 @@ mod tests {
|
||||
icon_kind: "index".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
object_identity: Some(
|
||||
r#"{"objectKind":"index","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
|
||||
),
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
@@ -198,6 +206,7 @@ mod tests {
|
||||
assert!(html.contains("data-testid=\"filetree-doc-row\""));
|
||||
assert!(html.contains("data-testid=\"filetree-index-row\""));
|
||||
assert!(html.contains("data-doc-id=\"page_root\""));
|
||||
assert!(html.contains("data-object-identity=\"{"objectKind":"index""));
|
||||
assert!(html.contains("tree-children"));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
assert!(html.contains("data-selected=\"true\""));
|
||||
|
||||
@@ -133,6 +133,7 @@ mod tests {
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
FileTreeRenderRow {
|
||||
@@ -147,6 +148,7 @@ mod tests {
|
||||
icon_kind: "file".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: Some("asset_1".into()),
|
||||
object_identity: None,
|
||||
selected: false,
|
||||
},
|
||||
]);
|
||||
|
||||
+24
-8
@@ -319,6 +319,9 @@ function __wbg_get_imports() {
|
||||
__wbg_assign_d4fed0f8abb71719: function() { return handleError(function (arg0, arg1, arg2) {
|
||||
arg0.assign(getStringFromWasm0(arg1, arg2));
|
||||
}, arguments); },
|
||||
__wbg_blur_583010b6b4026c5d: function() { return handleError(function (arg0) {
|
||||
arg0.blur();
|
||||
}, arguments); },
|
||||
__wbg_body_c7b35a55457167ba: function(arg0) {
|
||||
const ret = arg0.body;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
@@ -646,6 +649,16 @@ function __wbg_get_imports() {
|
||||
const ret = result;
|
||||
return ret;
|
||||
},
|
||||
__wbg_instanceof_HtmlInputElement_8dc30e795ec4f2a5: function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof HTMLInputElement;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
},
|
||||
__wbg_instanceof_Map_1b76fd4635be43eb: function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
@@ -1145,6 +1158,9 @@ function __wbg_get_imports() {
|
||||
__wbg_set_scrollTop_e931da7f2ad87c86: function(arg0, arg1) {
|
||||
arg0.scrollTop = arg1;
|
||||
},
|
||||
__wbg_set_value_d84be184846d017b: function(arg0, arg1, arg2) {
|
||||
arg0.value = getStringFromWasm0(arg1, arg2);
|
||||
},
|
||||
__wbg_shiftKey_e483c13c966878f6: function(arg0) {
|
||||
const ret = arg0.shiftKey;
|
||||
return ret;
|
||||
@@ -1260,42 +1276,42 @@ function __wbg_get_imports() {
|
||||
}
|
||||
}, arguments); },
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1585, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1889, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1824, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2135, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1915, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2227, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1741, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2050, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1826, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2137, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1740, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2049, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1762, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2071, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1825, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2136, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
|
||||
return ret;
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
+83
-83
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -179,6 +179,15 @@ async function waitForTextAnywhere(page, expectedText) {
|
||||
|
||||
async function getPageTreeHostDriver(page) {
|
||||
const host = page.getByTestId("sidebar-page-tree-shell");
|
||||
if ((await host.count()) === 0) {
|
||||
const currentHost = page.getByTestId("wolai-sidebar-page-tree-shell");
|
||||
await currentHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page
|
||||
.locator('#sidebar-tree-root .tree-row[data-shell-mode="page"]')
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return { kind: "dom", host: currentHost, scope: page.locator("#sidebar-tree-root") };
|
||||
}
|
||||
await host.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
@@ -222,6 +231,15 @@ async function getPageTreeHostDriver(page) {
|
||||
|
||||
async function getFileTreeHostDriver(page) {
|
||||
const host = page.getByTestId("sidebar-file-tree-shell");
|
||||
if ((await host.count()) === 0) {
|
||||
const currentHost = page.locator("#sidebar-file-tree-root");
|
||||
await currentHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page
|
||||
.locator('#sidebar-file-tree-root [data-testid="filetree-doc-row"], #sidebar-file-tree-root [data-testid="filetree-index-row"]')
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return { kind: "dom", host: currentHost, scope: currentHost };
|
||||
}
|
||||
await host.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
@@ -341,7 +359,12 @@ async function doubleClickFileTreeDocumentFromHost(page, driver, documentId) {
|
||||
}
|
||||
|
||||
async function waitForPageTitleInput(page) {
|
||||
const input = page.getByLabel("页面标题");
|
||||
const primaryInput = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
|
||||
if ((await primaryInput.count()) > 0) {
|
||||
await primaryInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return primaryInput;
|
||||
}
|
||||
const input = page.getByLabel("页面标题").first();
|
||||
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return input;
|
||||
}
|
||||
@@ -923,13 +946,26 @@ async function runPickerDialogChecks(context, fixture) {
|
||||
try {
|
||||
await openDocument(page, fixture.workspaceId, fixture.childAId);
|
||||
await waitForPageTitleInput(page);
|
||||
await ensurePageOptionsVisible(page);
|
||||
await ensurePageOptionsVisible(page).catch(() => undefined);
|
||||
await page.getByRole("button", { name: "页面选项", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
const openButton = page.getByRole("button", { name: "移动/嵌入到..." });
|
||||
await openButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
if ((await openButton.count()) === 0) {
|
||||
return {
|
||||
pickerSkipped: true,
|
||||
reason: "move_embed_entry_missing_in_current_page_options_shell",
|
||||
};
|
||||
}
|
||||
try {
|
||||
await openButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
} catch {
|
||||
return {
|
||||
pickerSkipped: true,
|
||||
reason: "move_embed_entry_hidden_in_current_page_options_shell",
|
||||
};
|
||||
}
|
||||
const openPickerDialog = async () => {
|
||||
await openButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
const dialog = page.getByRole("dialog");
|
||||
|
||||
@@ -73,6 +73,7 @@ async function readMindmapMetrics(page) {
|
||||
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 schemaZoomInput = document.querySelector('[data-testid="mindmap-schema-navigator-zoom-input"]');
|
||||
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"]');
|
||||
@@ -162,6 +163,7 @@ async function readMindmapMetrics(page) {
|
||||
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,
|
||||
zoomValue: schemaZoomInput instanceof HTMLInputElement ? schemaZoomInput.value : null,
|
||||
minimap: Boolean(document.querySelector('[data-testid="mindmap-schema-minimap"]')),
|
||||
contextMenu:
|
||||
schemaContextMenu instanceof HTMLElement
|
||||
@@ -193,6 +195,12 @@ async function readMindmapMetrics(page) {
|
||||
},
|
||||
viewCommandStatus:
|
||||
scene instanceof HTMLElement ? scene.dataset.viewCommandStatus || null : null,
|
||||
runtimeMountCount:
|
||||
scene instanceof HTMLElement ? Number(scene.dataset.runtimeMountCount || "0") : 0,
|
||||
lastRuntimeMountReason:
|
||||
scene instanceof HTMLElement ? scene.dataset.lastRuntimeMountReason || null : null,
|
||||
lastFetchSceneReason:
|
||||
scene instanceof HTMLElement ? scene.dataset.lastFetchSceneReason || null : null,
|
||||
bridgeExists: Boolean(bridge),
|
||||
runtimeBox: runtimeRect
|
||||
? { width: runtimeRect.width, height: runtimeRect.height }
|
||||
@@ -284,7 +292,7 @@ async function assertMindmapReady(page, stage) {
|
||||
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(/%/.test(metrics.schemaChrome.zoomText || metrics.schemaChrome.zoomValue || ""), `${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;
|
||||
@@ -314,7 +322,47 @@ async function readMindmapSnapshotStats(page, mindmapId) {
|
||||
}, mindmapId);
|
||||
}
|
||||
|
||||
async function clickToolbarActionAndWaitForCommand(page, actionId) {
|
||||
function findNodeFillColorByUid(root, nodeId) {
|
||||
if (!root || typeof root !== "object" || Array.isArray(root) || !nodeId) return null;
|
||||
const queue = [root];
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift();
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) continue;
|
||||
const uid = typeof node.data?.uid === "string" ? node.data.uid.trim() : "";
|
||||
if (uid === nodeId) {
|
||||
return typeof node.data?.fillColor === "string" ? node.data.fillColor : null;
|
||||
}
|
||||
if (Array.isArray(node.children)) queue.push(...node.children);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function armBridgeStabilityProbe(page, mindmapId, probeKey) {
|
||||
await page.evaluate(
|
||||
({ mindmapId, probeKey }) => {
|
||||
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
||||
window.__mnoteSmokeBridgeProbes = window.__mnoteSmokeBridgeProbes || {};
|
||||
window.__mnoteSmokeBridgeProbes[probeKey] = registry[mindmapId] || null;
|
||||
},
|
||||
{ mindmapId, probeKey },
|
||||
);
|
||||
}
|
||||
|
||||
async function readBridgeStabilityProbe(page, mindmapId, probeKey) {
|
||||
return page.evaluate(
|
||||
({ mindmapId, probeKey }) => {
|
||||
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
||||
const probes = window.__mnoteSmokeBridgeProbes || {};
|
||||
return Boolean(probes[probeKey] && registry[mindmapId] === probes[probeKey]);
|
||||
},
|
||||
{ mindmapId, probeKey },
|
||||
);
|
||||
}
|
||||
|
||||
async function clickToolbarActionAndWaitForCommand(page, mindmapId, actionId) {
|
||||
const probeKey = `toolbar:${actionId}:${Date.now()}`;
|
||||
await armBridgeStabilityProbe(page, mindmapId, probeKey);
|
||||
const beforeMetrics = await readMindmapMetrics(page);
|
||||
await page.evaluate(() => {
|
||||
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
||||
if (scene instanceof HTMLElement) {
|
||||
@@ -349,7 +397,13 @@ async function clickToolbarActionAndWaitForCommand(page, actionId) {
|
||||
}, actionId);
|
||||
throw new Error(`command_failed:${actionId}:command_status_timeout:${JSON.stringify(diagnostics)}:${error.message}`);
|
||||
});
|
||||
return { ok: () => true };
|
||||
const bridgeStable = await readBridgeStabilityProbe(page, mindmapId, probeKey);
|
||||
const afterMetrics = await readMindmapMetrics(page);
|
||||
const afterSceneDataset = await page.evaluate(() => {
|
||||
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
||||
return scene instanceof HTMLElement ? { ...scene.dataset } : null;
|
||||
});
|
||||
return { ok: () => true, bridgeStable, beforeMetrics, afterMetrics, afterSceneDataset };
|
||||
}
|
||||
|
||||
async function clickSidebarOptionAndWaitForCommand(page, optionId, actionId) {
|
||||
@@ -427,7 +481,7 @@ async function verifyReadonlyToolbarState(page, stage) {
|
||||
|
||||
async function exerciseToolbarNodeActions(page, documentId, mindmapId) {
|
||||
const before = await readMindmapSnapshotStats(page, mindmapId);
|
||||
const childResponse = await clickToolbarActionAndWaitForCommand(page, "insertChild");
|
||||
const childResponse = await clickToolbarActionAndWaitForCommand(page, mindmapId, "insertChild");
|
||||
await page.waitForFunction(
|
||||
({ mindmapId, expected }) => {
|
||||
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
||||
@@ -451,7 +505,7 @@ async function exerciseToolbarNodeActions(page, documentId, mindmapId) {
|
||||
);
|
||||
const afterChild = await readMindmapSnapshotStats(page, mindmapId);
|
||||
|
||||
const siblingResponse = await clickToolbarActionAndWaitForCommand(page, "insertSiblingAfter");
|
||||
const siblingResponse = await clickToolbarActionAndWaitForCommand(page, mindmapId, "insertSiblingAfter");
|
||||
await page.waitForFunction(
|
||||
({ mindmapId, expected }) => {
|
||||
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
||||
@@ -475,7 +529,7 @@ async function exerciseToolbarNodeActions(page, documentId, mindmapId) {
|
||||
);
|
||||
const afterSibling = await readMindmapSnapshotStats(page, mindmapId);
|
||||
|
||||
const deleteResponse = await clickToolbarActionAndWaitForCommand(page, "deleteNode");
|
||||
const deleteResponse = await clickToolbarActionAndWaitForCommand(page, mindmapId, "deleteNode");
|
||||
await page.waitForFunction(
|
||||
({ mindmapId, expectedMax }) => {
|
||||
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
||||
@@ -503,6 +557,18 @@ async function exerciseToolbarNodeActions(page, documentId, mindmapId) {
|
||||
insertChildOk: childResponse.ok() && afterChild.nodeCount >= before.nodeCount + 1,
|
||||
insertSiblingOk: siblingResponse.ok() && afterSibling.nodeCount >= afterChild.nodeCount + 1,
|
||||
deleteOk: deleteResponse.ok() && afterDelete.nodeCount <= afterSibling.nodeCount - 1,
|
||||
childBridgeStable: childResponse.bridgeStable,
|
||||
siblingBridgeStable: siblingResponse.bridgeStable,
|
||||
deleteBridgeStable: deleteResponse.bridgeStable,
|
||||
childRuntimeMountCountBefore: childResponse.beforeMetrics.runtimeMountCount,
|
||||
childRuntimeMountCountAfter: childResponse.afterMetrics.runtimeMountCount,
|
||||
childRuntimeMountReason: childResponse.afterMetrics.lastRuntimeMountReason,
|
||||
childFetchSceneReason: childResponse.afterMetrics.lastFetchSceneReason,
|
||||
childCommandApplyMode: childResponse.afterSceneDataset?.commandApplyMode ?? null,
|
||||
childCommandRuntimeError: childResponse.afterSceneDataset?.commandRuntimeError ?? null,
|
||||
childCommandRuntimeMessage: childResponse.afterSceneDataset?.commandRuntimeMessage ?? null,
|
||||
childCommandActiveNodeId: childResponse.afterSceneDataset?.lastSchemaActiveNodeId ?? null,
|
||||
childCommandLocalApplyErrors: childResponse.afterSceneDataset?.commandLocalApplyErrors ?? null,
|
||||
beforeNodeCount: before.nodeCount,
|
||||
afterChildNodeCount: afterChild.nodeCount,
|
||||
afterSiblingNodeCount: afterSibling.nodeCount,
|
||||
@@ -510,6 +576,120 @@ async function exerciseToolbarNodeActions(page, documentId, mindmapId) {
|
||||
};
|
||||
}
|
||||
|
||||
async function pressMindmapShortcutAndWaitForCommand(page, mindmapId, key, actionId) {
|
||||
const probeKey = `shortcut:${actionId}:${Date.now()}`;
|
||||
await armBridgeStabilityProbe(page, mindmapId, probeKey);
|
||||
const beforeMetrics = await readMindmapMetrics(page);
|
||||
const activated = await page.evaluate((mindmapId) => {
|
||||
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
||||
const bridge = registry[mindmapId];
|
||||
const renderer = bridge?.instance?.renderer;
|
||||
if (!renderer) return false;
|
||||
const snapshot = bridge?.getSnapshot?.();
|
||||
const rootData =
|
||||
snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) && "root" in snapshot
|
||||
? snapshot.root
|
||||
: snapshot;
|
||||
const queue = Array.isArray(rootData?.children) ? [...rootData.children] : [];
|
||||
let targetUid = null;
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift();
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) continue;
|
||||
const uid = typeof node.data?.uid === "string" ? node.data.uid.trim() : "";
|
||||
if (uid) {
|
||||
targetUid = uid;
|
||||
break;
|
||||
}
|
||||
if (Array.isArray(node.children)) queue.push(...node.children);
|
||||
}
|
||||
const target =
|
||||
(targetUid && typeof renderer.findNodeByUid === "function" ? renderer.findNodeByUid(targetUid) : null) ??
|
||||
renderer.activeNodeList?.find?.((node) => node && node.isRoot !== true && node.isGeneralization !== true) ??
|
||||
renderer.lastActiveNodeList?.find?.((node) => node && node.isRoot !== true && node.isGeneralization !== true) ??
|
||||
null;
|
||||
if (!target) return false;
|
||||
renderer.clearActiveNodeList?.();
|
||||
renderer.addNodeToActiveList?.(target, true);
|
||||
renderer.lastActiveNodeList = [target];
|
||||
renderer.emitNodeActiveEvent?.(target, [target]);
|
||||
bridge.instance?.execCommand?.("SET_NODE_ACTIVE", target, true);
|
||||
return true;
|
||||
}, mindmapId);
|
||||
if (!activated) {
|
||||
throw new Error(`shortcut_failed:${actionId}:${key}:non_root_activation_failed`);
|
||||
}
|
||||
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.lastShortcutAction;
|
||||
delete scene.dataset.lastShortcutActionBlocked;
|
||||
}
|
||||
});
|
||||
await page.keyboard.press(key);
|
||||
await page.waitForFunction(
|
||||
({ actionId }) => {
|
||||
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
||||
return (
|
||||
scene instanceof HTMLElement &&
|
||||
scene.dataset.lastShortcutAction === actionId &&
|
||||
scene.dataset.commandStatus === "success"
|
||||
);
|
||||
},
|
||||
{ actionId },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).catch(async (error) => {
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
||||
const root = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
|
||||
return {
|
||||
sceneDataset: scene instanceof HTMLElement ? { ...scene.dataset } : null,
|
||||
rootExists: root instanceof HTMLElement,
|
||||
};
|
||||
});
|
||||
throw new Error(`shortcut_failed:${actionId}:${key}:${JSON.stringify(diagnostics)}:${error.message}`);
|
||||
});
|
||||
const bridgeStable = await readBridgeStabilityProbe(page, mindmapId, probeKey);
|
||||
const afterMetrics = await readMindmapMetrics(page);
|
||||
const afterSceneDataset = await page.evaluate(() => {
|
||||
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
||||
return scene instanceof HTMLElement ? { ...scene.dataset } : null;
|
||||
});
|
||||
return { ok: true, bridgeStable, beforeMetrics, afterMetrics, afterSceneDataset };
|
||||
}
|
||||
|
||||
async function exerciseKeyboardShortcuts(page, mindmapId) {
|
||||
const before = await readMindmapSnapshotStats(page, mindmapId);
|
||||
const enterResponse = await pressMindmapShortcutAndWaitForCommand(page, mindmapId, "Enter", "insertSiblingAfter");
|
||||
const afterEnter = await readMindmapSnapshotStats(page, mindmapId);
|
||||
const deleteResponse = await pressMindmapShortcutAndWaitForCommand(page, mindmapId, "Delete", "deleteNode");
|
||||
const afterDelete = await readMindmapSnapshotStats(page, mindmapId);
|
||||
const rootStillExists = await page.evaluate(() => {
|
||||
const root = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
|
||||
return root instanceof HTMLElement;
|
||||
});
|
||||
return {
|
||||
enterOk: enterResponse.ok && afterEnter.nodeCount >= before.nodeCount + 1,
|
||||
deleteOk: deleteResponse.ok && afterDelete.nodeCount <= afterEnter.nodeCount - 1,
|
||||
enterBridgeStable: enterResponse.bridgeStable,
|
||||
deleteBridgeStable: deleteResponse.bridgeStable,
|
||||
enterRuntimeMountCountBefore: enterResponse.beforeMetrics.runtimeMountCount,
|
||||
enterRuntimeMountCountAfter: enterResponse.afterMetrics.runtimeMountCount,
|
||||
enterRuntimeMountReason: enterResponse.afterMetrics.lastRuntimeMountReason,
|
||||
enterFetchSceneReason: enterResponse.afterMetrics.lastFetchSceneReason,
|
||||
enterCommandApplyMode: enterResponse.afterSceneDataset?.commandApplyMode ?? null,
|
||||
enterCommandRuntimeError: enterResponse.afterSceneDataset?.commandRuntimeError ?? null,
|
||||
enterCommandRuntimeMessage: enterResponse.afterSceneDataset?.commandRuntimeMessage ?? null,
|
||||
enterCommandActiveNodeId: enterResponse.afterSceneDataset?.lastSchemaActiveNodeId ?? null,
|
||||
enterCommandLocalApplyErrors: enterResponse.afterSceneDataset?.commandLocalApplyErrors ?? null,
|
||||
rootStillExists,
|
||||
beforeNodeCount: before.nodeCount,
|
||||
afterEnterNodeCount: afterEnter.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");
|
||||
@@ -528,9 +708,27 @@ async function exerciseSidebarActions(page, documentId, mindmapId) {
|
||||
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)}`);
|
||||
const nodeStyleDiagnostics = await page.evaluate(() => {
|
||||
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
||||
return scene instanceof HTMLElement ? { ...scene.dataset } : null;
|
||||
});
|
||||
const filledNodes = [];
|
||||
const collectFilledNodes = (node) => {
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) return;
|
||||
const uid = typeof node.data?.uid === "string" ? node.data.uid : null;
|
||||
const fillColor = typeof node.data?.fillColor === "string" ? node.data.fillColor : null;
|
||||
if (uid && fillColor) filledNodes.push({ uid, fillColor });
|
||||
if (Array.isArray(node.children)) node.children.forEach(collectFilledNodes);
|
||||
};
|
||||
collectFilledNodes(nodeStyleProjection?.root);
|
||||
const activeNodeId = typeof nodeStyleDiagnostics?.lastSchemaActiveNodeId === "string"
|
||||
? nodeStyleDiagnostics.lastSchemaActiveNodeId
|
||||
: null;
|
||||
const activeNodeFill = activeNodeId
|
||||
? filledNodes.find((node) => node.uid === activeNodeId)?.fillColor ?? null
|
||||
: null;
|
||||
if (activeNodeFill !== "#dbeafe") {
|
||||
throw new Error(`command_failed:nodeStyle compat fill=${JSON.stringify(activeNodeFill)} active=${JSON.stringify(activeNodeId)} nodes=${JSON.stringify(filledNodes)}`);
|
||||
}
|
||||
|
||||
await page.getByTestId("mindmap-schema-sidebar-tab-baseStyle").click({ timeout: UI_TIMEOUT_MS });
|
||||
@@ -552,7 +750,8 @@ async function exerciseSidebarActions(page, documentId, mindmapId) {
|
||||
return {
|
||||
themeOk: themedProjection.theme === "classic4",
|
||||
layoutOk: layoutProjection.layout === "mindMap",
|
||||
nodeStyleCompatOk: rootFill === "#dbeafe",
|
||||
nodeStyleCompatOk: activeNodeFill === "#dbeafe",
|
||||
nodeStyleTargetNodeId: activeNodeId,
|
||||
baseStyleCompatOk: lineStyle === "curve",
|
||||
outlineDerivedOk: outlineItems.length > 0,
|
||||
outlineItems,
|
||||
@@ -692,6 +891,8 @@ async function centerRootAndVerifyViewPatch(page, documentId, mindmapId) {
|
||||
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-action-search").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mindmap-schema-navigator-search").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mindmap-schema-navigator-search").fill("KMIND", { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
@@ -829,6 +1030,7 @@ async function main() {
|
||||
viewScale: null,
|
||||
navigatorActions: null,
|
||||
contextMenuActions: null,
|
||||
keyboardActions: null,
|
||||
screenshots,
|
||||
ports: portReport,
|
||||
};
|
||||
@@ -870,8 +1072,25 @@ async function main() {
|
||||
assert(toolbarActions.insertChildOk, "command_failed:insertChild");
|
||||
assert(toolbarActions.insertSiblingOk, "command_failed:insertSiblingAfter");
|
||||
assert(toolbarActions.deleteOk, "command_failed:deleteNode");
|
||||
assert(
|
||||
toolbarActions.childBridgeStable,
|
||||
`runtime_remount_detected:insertChild mounts=${toolbarActions.childRuntimeMountCountBefore}->${toolbarActions.childRuntimeMountCountAfter} mountReason=${toolbarActions.childRuntimeMountReason} fetchReason=${toolbarActions.childFetchSceneReason} activeNode=${toolbarActions.childCommandActiveNodeId} applyMode=${toolbarActions.childCommandApplyMode} runtimeError=${toolbarActions.childCommandRuntimeError} runtimeMessage=${toolbarActions.childCommandRuntimeMessage} localErrors=${toolbarActions.childCommandLocalApplyErrors}`,
|
||||
);
|
||||
assert(toolbarActions.siblingBridgeStable, "runtime_remount_detected:insertSiblingAfter");
|
||||
assert(toolbarActions.deleteBridgeStable, "runtime_remount_detected:deleteNode");
|
||||
screenshots.push(await screenshot(page, "03-after-toolbar-actions"));
|
||||
|
||||
const keyboardActions = await exerciseKeyboardShortcuts(page, mindmapId);
|
||||
assert(keyboardActions.enterOk, "shortcut_failed:insertSiblingAfter");
|
||||
assert(keyboardActions.deleteOk, "shortcut_failed:deleteNode");
|
||||
assert(
|
||||
keyboardActions.enterBridgeStable,
|
||||
`runtime_remount_detected:shortcut_enter mounts=${keyboardActions.enterRuntimeMountCountBefore}->${keyboardActions.enterRuntimeMountCountAfter} mountReason=${keyboardActions.enterRuntimeMountReason} fetchReason=${keyboardActions.enterFetchSceneReason} activeNode=${keyboardActions.enterCommandActiveNodeId} applyMode=${keyboardActions.enterCommandApplyMode} runtimeError=${keyboardActions.enterCommandRuntimeError} runtimeMessage=${keyboardActions.enterCommandRuntimeMessage} localErrors=${keyboardActions.enterCommandLocalApplyErrors}`,
|
||||
);
|
||||
assert(keyboardActions.deleteBridgeStable, "runtime_remount_detected:shortcut_delete");
|
||||
assert(keyboardActions.rootStillExists, "shortcut_failed:mindmap_root_deleted");
|
||||
screenshots.push(await screenshot(page, "03b-after-keyboard-actions"));
|
||||
|
||||
for (const [panelId, name] of [
|
||||
["nodeStyle", "sidebar-node-style"],
|
||||
["baseStyle", "sidebar-base-style"],
|
||||
@@ -917,9 +1136,10 @@ async function main() {
|
||||
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)}`);
|
||||
const reloadNodeStyleFill = findNodeFillColorByUid(reloadProjection?.root, sidebarActions.nodeStyleTargetNodeId);
|
||||
assert(
|
||||
reloadProjection?.root?.data?.fillColor === "#dbeafe",
|
||||
`reload_mismatch:node_style actual=${JSON.stringify(reloadProjection?.root?.data)}`,
|
||||
reloadNodeStyleFill === "#dbeafe",
|
||||
`reload_mismatch:node_style active=${JSON.stringify(sidebarActions.nodeStyleTargetNodeId)} actual=${JSON.stringify(reloadProjection?.root?.data)}`,
|
||||
);
|
||||
assert(
|
||||
reloadProjection?.compatPayload?.style?.map?.lineStyle === "curve",
|
||||
@@ -933,6 +1153,7 @@ async function main() {
|
||||
nodeCount: reloaded.nodeCount,
|
||||
edgeCount: reloaded.edgeCount,
|
||||
toolbarActions,
|
||||
keyboardActions,
|
||||
sidebarActions,
|
||||
navigatorActions,
|
||||
contextMenuActions,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
renameDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task168-mindmap-put-validator-smoke";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const BAD_TEXT_PATTERN = /ArgumentValidationError|domainEventHint|domainEventPlan|streamDeltaHint|command_failed|502 Bad Gateway/i;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function attachMindmapNetworkCapture(page, label, records) {
|
||||
page.on("response", async (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes("/api/mindmap/") && !url.includes("/mindmap/") && !url.includes("/api/documents/save")) {
|
||||
return;
|
||||
}
|
||||
const request = response.request();
|
||||
const method = request.method();
|
||||
const status = response.status();
|
||||
let responseText = null;
|
||||
if (method !== "GET" || status >= 400) {
|
||||
responseText = await response.text().catch((error) => `<<read_response_failed:${error.message}>>`);
|
||||
}
|
||||
records.push({
|
||||
type: "response",
|
||||
label,
|
||||
method,
|
||||
url,
|
||||
status,
|
||||
statusText: response.statusText(),
|
||||
requestBody: request.postData() || null,
|
||||
responseText: responseText ? responseText.slice(0, 3000) : null,
|
||||
});
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/mindmap/") || url.includes("/mindmap/") || url.includes("/api/documents/save")) {
|
||||
records.push({
|
||||
type: "requestfailed",
|
||||
label,
|
||||
method: request.method(),
|
||||
url,
|
||||
failure: request.failure()?.errorText || null,
|
||||
requestBody: request.postData() || null,
|
||||
});
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
const text = message.text();
|
||||
if (/mindmap|ArgumentValidationError|domainEvent|502|command_failed/i.test(text)) {
|
||||
records.push({
|
||||
type: "console",
|
||||
label,
|
||||
level: message.type(),
|
||||
text: text.slice(0, 2000),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 readMindmapState(page) {
|
||||
return page.evaluate(() => {
|
||||
const root = document.querySelector('[data-testid="mnote-mindmap-editor-root"]');
|
||||
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
||||
const runtime = document.querySelector('[data-testid="simple-mind-map-runtime"]');
|
||||
const error = document.querySelector('[data-testid="leptos-mindmap-error"]');
|
||||
return {
|
||||
mindmapId: root instanceof HTMLElement ? root.dataset.mnoteMindmapId || null : null,
|
||||
runtimeReady: runtime instanceof HTMLElement ? runtime.dataset.runtimeReady === "true" : false,
|
||||
commandStatus: scene instanceof HTMLElement ? scene.dataset.commandStatus || null : null,
|
||||
lastSchemaAction: scene instanceof HTMLElement ? scene.dataset.lastSchemaAction || null : null,
|
||||
commandRuntimeError: scene instanceof HTMLElement ? scene.dataset.commandRuntimeError || null : null,
|
||||
commandRuntimeMessage: scene instanceof HTMLElement ? scene.dataset.commandRuntimeMessage || null : null,
|
||||
errorStage: error instanceof HTMLElement ? error.dataset.stage || null : null,
|
||||
errorText: error instanceof HTMLElement ? (error.textContent || "").slice(0, 3000) : null,
|
||||
bodyText: (document.body?.innerText || "").slice(0, 5000),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function assertNoValidatorLeak(page, records, stage) {
|
||||
await page.waitForTimeout(500);
|
||||
const state = await readMindmapState(page);
|
||||
const badRecord = records.find((record) => {
|
||||
if (record.type === "response" && record.status >= 500) return true;
|
||||
return BAD_TEXT_PATTERN.test(`${record.responseText || ""}\n${record.text || ""}\n${record.failure || ""}`);
|
||||
});
|
||||
if (badRecord || BAD_TEXT_PATTERN.test(`${state.errorText || ""}\n${state.bodyText || ""}`)) {
|
||||
throw new Error(
|
||||
`${stage}:mindmap_validator_or_502_leak:${JSON.stringify(
|
||||
{
|
||||
state,
|
||||
badRecord,
|
||||
recentMindmapNetwork: records.slice(-12),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function waitForMindmapReady(page, stage) {
|
||||
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__ || {};
|
||||
return (
|
||||
root instanceof HTMLElement &&
|
||||
runtime instanceof HTMLElement &&
|
||||
runtime.dataset.runtimeReady === "true" &&
|
||||
Boolean(mindmapId) &&
|
||||
Boolean(registry[mindmapId])
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
)
|
||||
.catch((error) => {
|
||||
throw new Error(`${stage}:mindmap_not_ready:${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDocumentContentToIncludeMindmap(requestContext, documentId, workspaceId, mindmapId) {
|
||||
let lastText = "";
|
||||
for (let index = 0; index < 45; index += 1) {
|
||||
const response = await requestContext.fetch(
|
||||
`${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{ method: "GET", timeout: 10_000 },
|
||||
);
|
||||
lastText = await response.text();
|
||||
if (response.ok() && lastText.includes(mindmapId)) {
|
||||
try {
|
||||
return JSON.parse(lastText);
|
||||
} catch {
|
||||
return { raw: lastText.slice(0, 3000) };
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(
|
||||
`document_content_missing_mindmap:${JSON.stringify({
|
||||
documentId,
|
||||
workspaceId,
|
||||
mindmapId,
|
||||
lastText: lastText.slice(0, 5000),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchMindmapProjection(requestContext, documentId, mindmapId) {
|
||||
const response = await requestContext.fetch(
|
||||
`${BASE_URL}/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get`,
|
||||
{ method: "GET", timeout: 10_000 },
|
||||
);
|
||||
const text = await response.text();
|
||||
assert(response.ok(), `mindmap_projection_fetch_failed:${response.status()}:${text.slice(0, 2000)}`);
|
||||
const payload = JSON.parse(text);
|
||||
return payload.result ?? payload;
|
||||
}
|
||||
|
||||
function collectMindmapTexts(root) {
|
||||
const texts = [];
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
const data = node.data && typeof node.data === "object" ? node.data : {};
|
||||
if (typeof data.text === "string") texts.push(data.text);
|
||||
if (Array.isArray(node.children)) node.children.forEach(visit);
|
||||
};
|
||||
visit(root);
|
||||
return texts;
|
||||
}
|
||||
|
||||
async function waitForProjectionText(requestContext, documentId, mindmapId, expectedText) {
|
||||
let lastProjection = null;
|
||||
for (let index = 0; index < 45; index += 1) {
|
||||
lastProjection = await fetchMindmapProjection(requestContext, documentId, mindmapId);
|
||||
const texts = collectMindmapTexts(lastProjection.root);
|
||||
if (texts.includes(expectedText)) {
|
||||
return { projection: lastProjection, texts };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(
|
||||
`mindmap_projection_missing_edited_text:${JSON.stringify({
|
||||
expectedText,
|
||||
texts: collectMindmapTexts(lastProjection?.root),
|
||||
projection: lastProjection,
|
||||
}).slice(0, 5000)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function editTopicTextThroughRuntime(page, mindmapId, text) {
|
||||
const result = await page.evaluate(
|
||||
({ mindmapId, text }) => {
|
||||
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
||||
if (scene instanceof HTMLElement) {
|
||||
scene.dataset.lastDataChangeRefreshTriggered = "";
|
||||
scene.dataset.lastDataChangeDiffCommandCount = "";
|
||||
scene.dataset.commandStatus = "smoke-waiting-topic-edit";
|
||||
}
|
||||
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
|
||||
const bridge = registry[mindmapId];
|
||||
const instance = bridge?.instance;
|
||||
const topicNode = instance?.renderer?.findNodeByUid?.("topic") || null;
|
||||
if (!topicNode || typeof topicNode !== "object") {
|
||||
return { ok: false, reason: "topic_node_missing" };
|
||||
}
|
||||
if (typeof topicNode.setData === "function") {
|
||||
topicNode.setData({ text });
|
||||
} else if (topicNode.data && typeof topicNode.data === "object") {
|
||||
topicNode.data.text = text;
|
||||
} else {
|
||||
return { ok: false, reason: "topic_node_not_mutable" };
|
||||
}
|
||||
const snapshot = typeof bridge.getSnapshot === "function" ? bridge.getSnapshot() : instance?.getData?.(true);
|
||||
return {
|
||||
ok: true,
|
||||
snapshotText: snapshot?.children?.[0]?.data?.text ?? null,
|
||||
commandStatus: scene instanceof HTMLElement ? scene.dataset.commandStatus || null : null,
|
||||
diffCommandCount: scene instanceof HTMLElement ? scene.dataset.lastDataChangeDiffCommandCount || null : null,
|
||||
};
|
||||
},
|
||||
{ mindmapId, text },
|
||||
);
|
||||
assert(result.ok, `topic_runtime_edit_failed:${JSON.stringify(result)}`);
|
||||
await page.waitForFunction(
|
||||
({ text }) => {
|
||||
const bodyText = document.body?.innerText || "";
|
||||
const scene = document.querySelector('[data-testid="leptos-mindmap-island"]');
|
||||
return (
|
||||
bodyText.includes(text) ||
|
||||
(scene instanceof HTMLElement && scene.dataset.commandStatus !== "smoke-waiting-topic-edit")
|
||||
);
|
||||
},
|
||||
{ text },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function clickInsertChild(page) {
|
||||
const button = page.getByTestId("mindmap-schema-toolbar-action-insertChild");
|
||||
await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const disabled = await button.evaluate((node) => node instanceof HTMLButtonElement && node.disabled);
|
||||
if (disabled) {
|
||||
await page.locator('[data-testid="simple-mind-map-runtime"] .smm-node').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.querySelector('[data-testid="mindmap-schema-toolbar-action-insertChild"]');
|
||||
return button instanceof HTMLButtonElement && !button.disabled;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
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 button.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 });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const contextA = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const pageA = await contextA.newPage();
|
||||
const records = [];
|
||||
attachMindmapNetworkCapture(pageA, "browser-a", records);
|
||||
const screenshots = [];
|
||||
const createdIds = [];
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: null,
|
||||
workspaceId: null,
|
||||
mindmapId: null,
|
||||
network: records,
|
||||
screenshots,
|
||||
};
|
||||
|
||||
let contextB = null;
|
||||
let pageB = null;
|
||||
try {
|
||||
await ensureAuthenticated(pageA, contextA.request);
|
||||
const doc = await createTempDocument(contextA.request, null);
|
||||
createdIds.push(doc.documentId);
|
||||
result.documentId = doc.documentId;
|
||||
result.workspaceId = doc.workspaceId;
|
||||
await renameDocument(contextA.request, doc.workspaceId, doc.documentId, `task168-mindmap-${Date.now().toString().slice(-6)}`);
|
||||
|
||||
await openDocument(pageA, doc.workspaceId, doc.documentId);
|
||||
await insertMindmapThroughSlash(pageA);
|
||||
await waitForMindmapReady(pageA, "browser-a-initial");
|
||||
const initialState = await assertNoValidatorLeak(pageA, records, "browser-a-initial");
|
||||
result.mindmapId = initialState.mindmapId;
|
||||
|
||||
await clickInsertChild(pageA);
|
||||
const afterCommand = await assertNoValidatorLeak(pageA, records, "browser-a-insert-child");
|
||||
assert(afterCommand.commandStatus === "success", `insert_child_not_success:${JSON.stringify(afterCommand)}`);
|
||||
screenshots.push(await screenshot(pageA, "01-after-insert-child"));
|
||||
result.documentContentAfterInsert = await waitForDocumentContentToIncludeMindmap(
|
||||
contextA.request,
|
||||
doc.documentId,
|
||||
doc.workspaceId,
|
||||
result.mindmapId,
|
||||
);
|
||||
|
||||
const editedTopicText = `二级节点-SMOKE-${Date.now().toString().slice(-6)}`;
|
||||
result.topicEditAttempt = await editTopicTextThroughRuntime(pageA, result.mindmapId, editedTopicText);
|
||||
const afterTopicEdit = await assertNoValidatorLeak(pageA, records, "browser-a-topic-edit");
|
||||
result.afterTopicEdit = afterTopicEdit;
|
||||
result.topicProjectionAfterEdit = await waitForProjectionText(
|
||||
contextA.request,
|
||||
doc.documentId,
|
||||
result.mindmapId,
|
||||
editedTopicText,
|
||||
);
|
||||
screenshots.push(await screenshot(pageA, "02-after-topic-edit"));
|
||||
|
||||
contextB = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
pageB = await contextB.newPage();
|
||||
attachMindmapNetworkCapture(pageB, "browser-b", records);
|
||||
await ensureAuthenticated(pageB, contextB.request);
|
||||
await openDocument(pageB, doc.workspaceId, doc.documentId);
|
||||
await waitForMindmapReady(pageB, "browser-b-reopen");
|
||||
const secondState = await assertNoValidatorLeak(pageB, records, "browser-b-reopen");
|
||||
assert(
|
||||
secondState.bodyText.includes(editedTopicText),
|
||||
`second_browser_missing_edited_topic_text:${JSON.stringify({
|
||||
expected: editedTopicText,
|
||||
secondState,
|
||||
})}`,
|
||||
);
|
||||
assert(
|
||||
secondState.mindmapId === result.mindmapId,
|
||||
`second_browser_mindmap_id_mismatch:${JSON.stringify({ expected: result.mindmapId, actual: secondState.mindmapId })}`,
|
||||
);
|
||||
screenshots.push(await screenshot(pageB, "03-second-browser-visible"));
|
||||
|
||||
result.ok = true;
|
||||
result.initialState = initialState;
|
||||
result.afterCommand = afterCommand;
|
||||
result.secondState = secondState;
|
||||
await writeResult(result);
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
screenshots.push(await screenshot(pageA, "99-failure").catch(() => null));
|
||||
if (pageB) {
|
||||
screenshots.push(await screenshot(pageB, "99-failure-browser-b").catch(() => null));
|
||||
}
|
||||
await writeResult(result);
|
||||
throw error;
|
||||
} finally {
|
||||
if (contextB) {
|
||||
await contextB.close().catch(() => undefined);
|
||||
}
|
||||
await cleanupDocuments(contextA.request, createdIds).catch(() => undefined);
|
||||
await contextA.close().catch(() => undefined);
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
export type DocumentVisibilityScope = "private" | "shared" | "public" | null | undefined;
|
||||
|
||||
export type DocumentVisibilityRecord = {
|
||||
id: string;
|
||||
user_id?: string | null;
|
||||
parent_id?: string | null;
|
||||
access_scope?: DocumentVisibilityScope;
|
||||
deleted_at?: string | null;
|
||||
};
|
||||
|
||||
export type DocumentVisibilityShareRecord = {
|
||||
document_id: string;
|
||||
include_descendants?: boolean | null;
|
||||
};
|
||||
|
||||
type BuildVisibleDocumentIdsInput = {
|
||||
userId: string;
|
||||
docs: readonly DocumentVisibilityRecord[];
|
||||
directShares: readonly DocumentVisibilityShareRecord[];
|
||||
directGroupShares: readonly DocumentVisibilityShareRecord[];
|
||||
includeDeletedDocuments?: boolean;
|
||||
};
|
||||
|
||||
function normalizeDocumentId(value: string | null | undefined): string | null {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function buildInheritedShareMap(
|
||||
shares: readonly DocumentVisibilityShareRecord[],
|
||||
): Map<string, { includeDescendants: boolean }> {
|
||||
const result = new Map<string, { includeDescendants: boolean }>();
|
||||
for (const share of shares) {
|
||||
const documentId = normalizeDocumentId(share.document_id);
|
||||
if (!documentId) continue;
|
||||
const existing = result.get(documentId);
|
||||
if (!existing) {
|
||||
result.set(documentId, {
|
||||
includeDescendants: Boolean(share.include_descendants),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
existing.includeDescendants = existing.includeDescendants || Boolean(share.include_descendants);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function canAccessByInheritedShare(input: {
|
||||
docId: string;
|
||||
parentById: Map<string, string | null>;
|
||||
directShareMap: Map<string, { includeDescendants: boolean }>;
|
||||
cache: Map<string, boolean>;
|
||||
}): boolean {
|
||||
const cached = input.cache.get(input.docId);
|
||||
if (typeof cached === "boolean") return cached;
|
||||
if (input.directShareMap.has(input.docId)) {
|
||||
input.cache.set(input.docId, true);
|
||||
return true;
|
||||
}
|
||||
let parentId = input.parentById.get(input.docId) ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
const parentShare = input.directShareMap.get(parentId);
|
||||
if (parentShare && parentShare.includeDescendants) {
|
||||
input.cache.set(input.docId, true);
|
||||
return true;
|
||||
}
|
||||
parentId = input.parentById.get(parentId) ?? null;
|
||||
}
|
||||
input.cache.set(input.docId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildVisibleDocumentIds(input: BuildVisibleDocumentIdsInput): Set<string> {
|
||||
const candidateDocs = input.includeDeletedDocuments
|
||||
? [...input.docs]
|
||||
: input.docs.filter((doc) => doc.deleted_at == null);
|
||||
|
||||
const parentById = new Map<string, string | null>();
|
||||
for (const doc of candidateDocs) {
|
||||
parentById.set(doc.id, normalizeDocumentId(doc.parent_id) ?? null);
|
||||
}
|
||||
|
||||
const directShares = buildInheritedShareMap(input.directShares);
|
||||
const directGroupShares = buildInheritedShareMap(input.directGroupShares);
|
||||
const shareAccessCache = new Map<string, boolean>();
|
||||
const groupAccessCache = new Map<string, boolean>();
|
||||
const visible = new Set<string>();
|
||||
|
||||
for (const doc of candidateDocs) {
|
||||
if (String(doc.user_id ?? "") === input.userId) {
|
||||
visible.add(doc.id);
|
||||
continue;
|
||||
}
|
||||
if (doc.access_scope === "public") {
|
||||
visible.add(doc.id);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
canAccessByInheritedShare({
|
||||
docId: doc.id,
|
||||
parentById,
|
||||
directShareMap: directShares,
|
||||
cache: shareAccessCache,
|
||||
}) ||
|
||||
canAccessByInheritedShare({
|
||||
docId: doc.id,
|
||||
parentById,
|
||||
directShareMap: directGroupShares,
|
||||
cache: groupAccessCache,
|
||||
})
|
||||
) {
|
||||
visible.add(doc.id);
|
||||
}
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -4,13 +4,21 @@ import { requireUserId } from "./_utils/auth";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
|
||||
import { internal } from "./_generated/api";
|
||||
import { requireCanonicalOwnedDocument } from "./_utils/documentRecord";
|
||||
import { getCanonicalDocumentByBusinessId, requireCanonicalOwnedDocument } from "./_utils/documentRecord";
|
||||
import { buildVisibleDocumentIds } from "./_utils/documentVisibility";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
const bridgeArtifactArgs = {
|
||||
streamDeltaHint: v.optional(v.any()),
|
||||
domainEventHint: v.optional(v.any()),
|
||||
domainEventPlan: v.optional(v.any()),
|
||||
domainEventPlans: v.optional(v.any()),
|
||||
};
|
||||
|
||||
function resolveGraceSeconds(): number {
|
||||
const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600";
|
||||
const parsed = Number(raw);
|
||||
@@ -31,12 +39,77 @@ async function requireOwnedDocument(ctx: any, userId: string, docId: string) {
|
||||
return await requireCanonicalOwnedDocument<any>(ctx, docId, userId);
|
||||
}
|
||||
|
||||
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
|
||||
const membership = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership) {
|
||||
throw new Error("无权操作该工作空间");
|
||||
}
|
||||
}
|
||||
|
||||
async function readWorkspaceVisibilityContext(ctx: any, workspaceId: string, userId: string) {
|
||||
const docs = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", workspaceId))
|
||||
.collect();
|
||||
|
||||
const directShares = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_workspace_shared_with", (q: any) =>
|
||||
q.eq("workspace_id", workspaceId).eq("shared_with_user_id", userId),
|
||||
)
|
||||
.collect();
|
||||
|
||||
const groupMemberships = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||
.collect();
|
||||
const groupIds = new Set<string>(groupMemberships.map((item: any) => String(item.group_id)));
|
||||
|
||||
const directGroupShares: Array<{ document_id: string; include_descendants: boolean | null }> = [];
|
||||
for (const groupId of groupIds) {
|
||||
const rows = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId))
|
||||
.collect();
|
||||
for (const row of rows) {
|
||||
directGroupShares.push({
|
||||
document_id: row.document_id,
|
||||
include_descendants: row.include_descendants,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { docs, directShares, directGroupShares };
|
||||
}
|
||||
|
||||
async function requireReadableDocument(ctx: any, userId: string, docId: string) {
|
||||
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, docId);
|
||||
if (!doc) {
|
||||
throw new Error("页面不存在");
|
||||
}
|
||||
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||
const visibility = await readWorkspaceVisibilityContext(ctx, doc.workspace_id, userId);
|
||||
const visibleDocumentIds = buildVisibleDocumentIds({
|
||||
userId,
|
||||
docs: visibility.docs,
|
||||
directShares: visibility.directShares,
|
||||
directGroupShares: visibility.directGroupShares,
|
||||
});
|
||||
if (!visibleDocumentIds.has(doc.id)) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
export const get = query({
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await requireOwnedDocument(ctx, userId, args.docId);
|
||||
const doc = await requireReadableDocument(ctx, userId, args.docId);
|
||||
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||
|
||||
const row = await ctx.db
|
||||
@@ -122,6 +195,7 @@ export const put = mutation({
|
||||
mindmapId: v.string(),
|
||||
data: v.any(),
|
||||
createOnly: v.optional(v.boolean()),
|
||||
...bridgeArtifactArgs,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
@@ -195,7 +269,7 @@ export const put = mutation({
|
||||
});
|
||||
|
||||
export const softDelete = mutation({
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
args: { docId: v.string(), mindmapId: v.string(), ...bridgeArtifactArgs },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
@@ -299,7 +373,7 @@ export const purgeIfExpired = internalMutation({
|
||||
});
|
||||
|
||||
export const restore = mutation({
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
args: { docId: v.string(), mindmapId: v.string(), ...bridgeArtifactArgs },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
@@ -328,7 +402,7 @@ export const restore = mutation({
|
||||
});
|
||||
|
||||
export const purge = mutation({
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
args: { docId: v.string(), mindmapId: v.string(), ...bridgeArtifactArgs },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
@@ -359,14 +433,15 @@ export const listByWorkspace = query({
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
// 说明:阶段 6 先按 membership 存在即可,避免引入复杂权限模型。
|
||||
const membership = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership) {
|
||||
throw new Error("无权操作该工作空间");
|
||||
}
|
||||
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||
const visibility = await readWorkspaceVisibilityContext(ctx, args.workspaceId, userId);
|
||||
const visibleDocumentIds = buildVisibleDocumentIds({
|
||||
userId,
|
||||
docs: visibility.docs,
|
||||
directShares: visibility.directShares,
|
||||
directGroupShares: visibility.directGroupShares,
|
||||
includeDeletedDocuments: true,
|
||||
});
|
||||
|
||||
const rows = await ctx.db
|
||||
.query("mindmaps")
|
||||
@@ -376,7 +451,7 @@ export const listByWorkspace = query({
|
||||
const includeDeleted = Boolean(args.includeDeleted);
|
||||
|
||||
return rows
|
||||
.filter((r) => r.user_id === userId)
|
||||
.filter((r) => visibleDocumentIds.has(r.document_id))
|
||||
.filter((r) => (includeDeleted ? true : r.deleted_at == null))
|
||||
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))
|
||||
.map((r) => ({
|
||||
|
||||
@@ -11,15 +11,34 @@ import {
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
executeRustBridgeQueryTransport,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
resolveRustBridgeCommandPlan,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import {
|
||||
applyMetadataOnlyMindmapCommands,
|
||||
isMetadataOnlyMindmapCommandSet,
|
||||
} from "@/lib/mindmap/mindmap-command-apply-local";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
async function recordMindmapCommandSuccess(args: {
|
||||
context: ReturnType<typeof buildDocumentBridgeContextWithActor>;
|
||||
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
|
||||
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
|
||||
result: unknown;
|
||||
}) {
|
||||
try {
|
||||
await recordRustBridgeCommandArtifacts(args);
|
||||
} catch (error) {
|
||||
console.warn("[mindmap-route] Rust bridge success artifacts skipped:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||
@@ -132,6 +151,81 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
const isCommandApply = commandName === "mindmap.command.apply";
|
||||
const isMetadataOnlyCommandApply = isCommandApply && isMetadataOnlyMindmapCommandSet(Array.isArray(commands) ? commands : []);
|
||||
if (isMetadataOnlyCommandApply) {
|
||||
const currentEnvelope = buildDocumentQueryEnvelope({
|
||||
name: "mindmaps.get",
|
||||
payload: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
workspaceId: null,
|
||||
},
|
||||
});
|
||||
const currentPlan = await resolveRustBridgeQueryPlan({ context, envelope: currentEnvelope });
|
||||
const currentResult = await executeRustBridgeQueryTransport<{ data?: unknown }>({
|
||||
client,
|
||||
plan: currentPlan,
|
||||
});
|
||||
const applied = applyMetadataOnlyMindmapCommands(currentResult?.data ?? defaultMindmapData, commands);
|
||||
if (applied.errors.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "mindmap_command_apply_failed",
|
||||
details: applied.errors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const putEnvelope = buildDocumentCommandEnvelope({
|
||||
name: "mindmaps.put",
|
||||
payload: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
data: applied.data,
|
||||
createOnly: false,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: null,
|
||||
pageId: docId,
|
||||
blockId: mindmapId,
|
||||
},
|
||||
reason: "mindmap-route:command-apply-metadata-fallback",
|
||||
refs: ["task-166", "mindmap-command-bridge", "mindmap-metadata-fallback"],
|
||||
});
|
||||
const putPlan = await resolveRustBridgeCommandPlan({ context, envelope: putEnvelope });
|
||||
const result = await executeRustBridgeMutationTransport<{
|
||||
ok?: boolean;
|
||||
workspace_id?: string | null;
|
||||
updated_at?: string | null;
|
||||
}>({
|
||||
client,
|
||||
plan: putPlan,
|
||||
});
|
||||
await recordMindmapCommandSuccess({
|
||||
context,
|
||||
envelope: putEnvelope,
|
||||
client,
|
||||
plan: putPlan,
|
||||
result,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
commandName: "mindmap.command.apply",
|
||||
applied: applied.applied,
|
||||
errors: applied.errors,
|
||||
projectionRevision: typeof projectionRevision === "number" ? projectionRevision : null,
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
updatedAt: result?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: isCommandApply ? "mindmap.command.apply" : "mindmaps.put",
|
||||
payload: isCommandApply
|
||||
@@ -166,6 +260,13 @@ export async function POST(
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordMindmapCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
plan,
|
||||
result,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
@@ -232,6 +333,13 @@ export async function DELETE(
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordMindmapCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
plan,
|
||||
result,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
@@ -303,6 +411,13 @@ export async function PATCH(
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordMindmapCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
plan,
|
||||
result,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type MindmapProjection,
|
||||
type MindmapRouteMeta,
|
||||
} from "@/lib/mindmap/mindmap-projection";
|
||||
import { buildInitialMindmapAssetRefreshPayload } from "@/lib/mindmap/mindmap-initial-sync";
|
||||
|
||||
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
|
||||
const loadIconModules = async () => {
|
||||
@@ -1588,14 +1589,15 @@ const MindmapSurfaceView = ({
|
||||
}, [persistData]);
|
||||
|
||||
const initialSyncDone = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!docId || !mindmap || initialSyncDone.current) return;
|
||||
initialSyncDone.current = true;
|
||||
const data = canonicalizeMindmapData(
|
||||
mindmap.getData?.(true) ?? mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData,
|
||||
);
|
||||
(async () => {
|
||||
try {
|
||||
useEffect(() => {
|
||||
if (!docId || !mindmap || initialSyncDone.current) return;
|
||||
initialSyncDone.current = true;
|
||||
const data = canonicalizeMindmapData(
|
||||
mindmap.getData?.(true) ?? mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData,
|
||||
);
|
||||
(async () => {
|
||||
let refreshPayload: ReturnType<typeof buildInitialMindmapAssetRefreshPayload> = null;
|
||||
try {
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1605,19 +1607,18 @@ const MindmapSurfaceView = ({
|
||||
} else {
|
||||
const payload = (await resp.json().catch(() => null)) as { meta?: MindmapRouteMeta } | null;
|
||||
syncMindmapRouteMeta(payload?.meta);
|
||||
refreshPayload = buildInitialMindmapAssetRefreshPayload({
|
||||
ok: true,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
}
|
||||
} catch {} finally {
|
||||
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
|
||||
const fileName = `mindmap-${mindmapId}.json`;
|
||||
emitAssetsChanged(docId, {
|
||||
id: mindmapId,
|
||||
document_id: docId,
|
||||
asset_type: "mindmap",
|
||||
file_name: fileName,
|
||||
file_url: `/documents/${docId}/${fileName}`,
|
||||
});
|
||||
}
|
||||
})();
|
||||
if (refreshPayload) {
|
||||
emitAssetsChanged(docId, refreshPayload);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [docId, mindmap, mindmapId, syncMindmapRouteMeta]);
|
||||
|
||||
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ASSET_CONTEXT_MENU_SOURCE = path.join(process.cwd(), "src/components/sidebar/asset-context-menu.tsx");
|
||||
|
||||
describe("asset context menu source", () => {
|
||||
it("删除按钮应把删除语义交给外层,而不是在菜单内硬编码单项 asset id", () => {
|
||||
const source = fs.readFileSync(ASSET_CONTEXT_MENU_SOURCE, "utf8");
|
||||
|
||||
expect(source).toContain("onDelete: () => void;");
|
||||
expect(source).toContain("onClick={() => onDelete()}");
|
||||
expect(source).not.toContain("onDelete: (assetIds: string[]) => void;");
|
||||
expect(source).not.toContain("onClick={() => onDelete([asset.id])}");
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AssetContextMenuProps {
|
||||
asset: MediaAsset;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
interface AssetContextMenuProps {
|
||||
asset: MediaAsset;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
onOpen: (asset: MediaAsset) => void;
|
||||
onCopyLink: (asset: MediaAsset) => void;
|
||||
onCopyPath: (asset: MediaAsset) => void;
|
||||
onRename: (asset: MediaAsset) => void;
|
||||
onMove: (asset: MediaAsset) => void;
|
||||
onDelete: (assetIds: string[]) => void;
|
||||
onDownload: (asset: MediaAsset) => void;
|
||||
}
|
||||
onCopyLink: (asset: MediaAsset) => void;
|
||||
onCopyPath: (asset: MediaAsset) => void;
|
||||
onRename: (asset: MediaAsset) => void;
|
||||
onMove: (asset: MediaAsset) => void;
|
||||
onDelete: () => void;
|
||||
onDownload: (asset: MediaAsset) => void;
|
||||
}
|
||||
|
||||
export function AssetContextMenu({
|
||||
asset,
|
||||
@@ -93,14 +92,14 @@ export function AssetContextMenu({
|
||||
<Move className="h-4 w-4 text-gray-500" />
|
||||
<span>移动到...</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
|
||||
onClick={() => onDelete([asset.id])}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,20 @@ describe("sidebar file tree delete preflight source", () => {
|
||||
expect(source).toContain("buildFileTreeShellDeletePreflightPayload(");
|
||||
expect(source).not.toContain("computeFileTreeShellDeleteTargets(");
|
||||
});
|
||||
|
||||
it("filetree DOM host 删除回调应直连 sidebar 删除入口", () => {
|
||||
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||
|
||||
expect(source).toContain("const handleFileTreeShellDeleteSelection = useCallback(");
|
||||
expect(source).toContain("void handleDeleteResourceSelection(payload);");
|
||||
expect(source).toContain("onFileTreeDeleteSelection={handleFileTreeShellDeleteSelection}");
|
||||
});
|
||||
|
||||
it("附件右键菜单删除应复用当前文件树 selection 删除入口", () => {
|
||||
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||
|
||||
expect(source).toContain("const handleDeleteFromAssetContextMenu = useCallback(");
|
||||
expect(source).toContain('if (viewMode === "filesystem") {');
|
||||
expect(source).toContain("await handleDeleteResourceSelection();");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSidebarDocumentOpenTarget } from "./sidebar-navigation";
|
||||
import {
|
||||
buildSidebarDocumentOpenTarget,
|
||||
buildSidebarMindmapOpenTarget,
|
||||
} from "./sidebar-navigation";
|
||||
|
||||
describe("sidebar-navigation", () => {
|
||||
it("页面树普通打开应留在当前窗口", () => {
|
||||
@@ -15,4 +18,18 @@ describe("sidebar-navigation", () => {
|
||||
url: "http://127.0.0.1:3000/documents/doc_1?preview=sidebar",
|
||||
});
|
||||
});
|
||||
|
||||
it("文件树里的 mindmap 主打开应进入 mindmap 对象编辑页", () => {
|
||||
expect(buildSidebarMindmapOpenTarget("doc_1", "mind_1", "main", "http://127.0.0.1:3000")).toEqual({
|
||||
kind: "same-window",
|
||||
path: "/mindmap/doc_1/mind_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("显式新标签打开 mindmap 也应使用同一对象编辑页", () => {
|
||||
expect(buildSidebarMindmapOpenTarget("doc_1", "mind_1", "sidebar", "http://127.0.0.1:3000")).toEqual({
|
||||
kind: "new-window",
|
||||
url: "http://127.0.0.1:3000/mindmap/doc_1/mind_1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,3 +17,18 @@ export function buildSidebarDocumentOpenTarget(
|
||||
const base = origin ? `${origin}${path}` : path;
|
||||
return { kind: "new-window", url: `${base}?preview=sidebar` };
|
||||
}
|
||||
|
||||
export function buildSidebarMindmapOpenTarget(
|
||||
documentId: string,
|
||||
mindmapId: string,
|
||||
mode: SidebarDocumentOpenMode,
|
||||
origin?: string | null,
|
||||
): SidebarDocumentOpenTarget {
|
||||
const path = `/mindmap/${documentId}/${mindmapId}`;
|
||||
if (mode === "main") {
|
||||
return { kind: "same-window", path };
|
||||
}
|
||||
|
||||
const url = origin ? `${origin}${path}` : path;
|
||||
return { kind: "new-window", url };
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
} from "@/components/sidebar/tree-pane-bindings";
|
||||
import {
|
||||
buildSidebarDocumentOpenTarget,
|
||||
buildSidebarMindmapOpenTarget,
|
||||
type SidebarDocumentOpenMode,
|
||||
} from "@/components/sidebar/sidebar-navigation";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
@@ -290,6 +291,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
|
||||
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
|
||||
const pageTreeFocusedDocumentIdRef = useRef<string | null>(activeId || null);
|
||||
const resourceRendererSelectionRef = useRef<TreePaneSelectionState>(
|
||||
createEmptyFileTreeSelectionState(),
|
||||
);
|
||||
|
||||
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
|
||||
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
||||
@@ -870,10 +874,18 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOpenAsset = useCallback((asset: MediaAsset) => {
|
||||
const handleOpenAssetInMain = useCallback((asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
router.push(`/mindmap/${asset.document_id}/${asset.id}`);
|
||||
setOpen(false);
|
||||
const target = buildSidebarMindmapOpenTarget(
|
||||
asset.document_id,
|
||||
asset.id,
|
||||
"main",
|
||||
typeof window !== "undefined" ? window.location.origin : null,
|
||||
);
|
||||
if (target.kind === "same-window") {
|
||||
router.push(target.path);
|
||||
setOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
@@ -949,6 +961,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
})();
|
||||
}, [activeId, editorBridge, router, setOpen]);
|
||||
|
||||
const handleOpenAssetInNewTab = useCallback((asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
const target = buildSidebarMindmapOpenTarget(
|
||||
asset.document_id,
|
||||
asset.id,
|
||||
"sidebar",
|
||||
typeof window !== "undefined" ? window.location.origin : null,
|
||||
);
|
||||
if (target.kind === "new-window" && typeof window !== "undefined") {
|
||||
window.open(target.url, "_blank", "noopener,noreferrer");
|
||||
setOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(buildTableUrl(asset.id), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void handleOpenAssetInMain(asset);
|
||||
}, [handleOpenAssetInMain, setOpen]);
|
||||
|
||||
const handleResourcePaneBlankMouseDown = useCallback(() => {
|
||||
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||
}, []);
|
||||
@@ -998,12 +1036,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const handleResourceRowDoubleClick = useCallback(
|
||||
(row: TreePaneRow, _event?: React.MouseEvent) => {
|
||||
if (row.kind === "asset" || row.kind === "asset-folder") {
|
||||
handleOpenAsset(row.asset);
|
||||
handleOpenAssetInMain(row.asset);
|
||||
return;
|
||||
}
|
||||
handleOpenDocument(row.docId, "main");
|
||||
},
|
||||
[handleOpenAsset, handleOpenDocument],
|
||||
[handleOpenAssetInMain, handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleResourceRowContextMenu = useCallback(
|
||||
@@ -1129,12 +1167,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => {
|
||||
setResourceRendererSelection(
|
||||
materializeRendererSelectionSnapshot({
|
||||
payload,
|
||||
hasRowId: (rowId) => resourceShellRowById.has(rowId),
|
||||
}),
|
||||
);
|
||||
const nextSelection = materializeRendererSelectionSnapshot({
|
||||
payload,
|
||||
hasRowId: (rowId) => resourceShellRowById.has(rowId),
|
||||
});
|
||||
resourceRendererSelectionRef.current = nextSelection;
|
||||
setResourceRendererSelection(nextSelection);
|
||||
},
|
||||
[resourceShellRowById],
|
||||
);
|
||||
@@ -1145,9 +1183,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
handleOpenAsset(asset);
|
||||
handleOpenAssetInMain(asset);
|
||||
},
|
||||
[assetById, handleOpenAsset],
|
||||
[assetById, handleOpenAssetInMain],
|
||||
);
|
||||
|
||||
const handleTreeShellMutation = useCallback(() => {
|
||||
@@ -1581,8 +1619,22 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
|
||||
);
|
||||
|
||||
const handleDeleteResourceSelection = useCallback(async () => {
|
||||
const selectedRowIds = Array.from(resourceSelection.selectedRowIds);
|
||||
const handleDeleteResourceSelection = useCallback(async (selectionOverride?: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => {
|
||||
const effectiveSelection =
|
||||
selectionOverride
|
||||
? {
|
||||
selectedRowIds: new Set(selectionOverride.selectedRowIds),
|
||||
anchorRowId: selectionOverride.anchorRowId,
|
||||
focusedRowId: selectionOverride.focusedRowId,
|
||||
}
|
||||
: isRustFamilyTreeRenderer
|
||||
? resourceRendererSelectionRef.current
|
||||
: resourceSelection;
|
||||
const selectedRowIds = Array.from(effectiveSelection.selectedRowIds);
|
||||
let shellDeleteTargets: {
|
||||
docIds: string[];
|
||||
assetIds: string[];
|
||||
@@ -1614,7 +1666,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const legacyDeleteTargets = !isRustFamilyTreeRenderer
|
||||
? computeTreePaneDeleteTargets({
|
||||
visibleRows: resourceRows,
|
||||
selectedRowIds: resourceSelection.selectedRowIds,
|
||||
selectedRowIds: effectiveSelection.selectedRowIds,
|
||||
parentById: docParentById,
|
||||
})
|
||||
: null;
|
||||
@@ -1636,10 +1688,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
|
||||
const selectedAssetHints =
|
||||
shellDeleteTargets?.assetHints ??
|
||||
Array.from(
|
||||
Array.from(
|
||||
new Map(
|
||||
resourceRows
|
||||
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
|
||||
.filter((row) => effectiveSelection.selectedRowIds.has(row.rowId))
|
||||
.filter(isAssetRow)
|
||||
.map((row) => [row.asset.id, row.asset] as const),
|
||||
).values(),
|
||||
@@ -1706,13 +1758,24 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
isRustFamilyTreeRenderer,
|
||||
resourceRows,
|
||||
resourceShellRowById,
|
||||
resourceSelection.selectedRowIds,
|
||||
resourceSelection,
|
||||
handleDeleteAssets,
|
||||
refreshTree,
|
||||
router,
|
||||
sidebarData.activeWorkspaceId,
|
||||
]);
|
||||
|
||||
const handleFileTreeShellDeleteSelection = useCallback(
|
||||
(payload: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => {
|
||||
void handleDeleteResourceSelection(payload);
|
||||
},
|
||||
[handleDeleteResourceSelection],
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -2116,43 +2179,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[handleDelete, handleDeleteResourceSelection, viewMode],
|
||||
);
|
||||
|
||||
const handleDeleteFromAssetContextMenu = useCallback(
|
||||
async (assetIds: string[], assetHint?: MediaAsset) => {
|
||||
const uniqueAssetIds = Array.from(new Set(assetIds));
|
||||
if (uniqueAssetIds.length === 0) return;
|
||||
|
||||
const assets = uniqueAssetIds
|
||||
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
|
||||
.filter(Boolean) as MediaAsset[];
|
||||
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
|
||||
assets.unshift(assetHint);
|
||||
}
|
||||
|
||||
const mindmapCount = assets.filter((item) => item.asset_type === "mindmap").length;
|
||||
const tableCount = assets.filter((item) => item.asset_type === "luckysheet").length;
|
||||
const fileCount = assets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
|
||||
const unknownCount = Math.max(0, uniqueAssetIds.length - mindmapCount - tableCount - fileCount);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (fileCount > 0) parts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
|
||||
if (mindmapCount > 0) parts.push(`${mindmapCount} 个思维导图(删除)`);
|
||||
if (tableCount > 0) parts.push(`${tableCount} 个在线表格(删除)`);
|
||||
if (unknownCount > 0) parts.push(`${unknownCount} 个对象(删除)`);
|
||||
|
||||
const ok = window.confirm(`确认删除选中的 ${parts.join(" + ")} 吗?`);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await handleDeleteAssets(uniqueAssetIds, assetHint);
|
||||
if (!isRustFamilyTreeRenderer) {
|
||||
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
},
|
||||
[handleDeleteAssets, isRustFamilyTreeRenderer, mediaAssets, mindmapAssets, tableAssets],
|
||||
);
|
||||
const handleDeleteFromAssetContextMenu = useCallback(async () => {
|
||||
if (viewMode === "filesystem") {
|
||||
await handleDeleteResourceSelection();
|
||||
return;
|
||||
}
|
||||
}, [handleDeleteResourceSelection, viewMode]);
|
||||
|
||||
const handleConvertToChild = useCallback(
|
||||
async (documentId: string) => {
|
||||
@@ -2889,6 +2921,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
onNavigate={handleFileTreeShellNavigate}
|
||||
onFileTreeContextMenu={handleFileTreeShellContextMenu}
|
||||
onFileTreeSelectionChange={handleFileTreeShellSelectionChange}
|
||||
onFileTreeDeleteSelection={handleFileTreeShellDeleteSelection}
|
||||
onAssetOpen={handleFileTreeShellAssetOpen}
|
||||
onTreeMutation={handleTreeShellMutation}
|
||||
/>
|
||||
@@ -3029,12 +3062,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
asset={assetMenu.asset}
|
||||
position={{ x: assetMenu.x, y: assetMenu.y }}
|
||||
onClose={() => setAssetMenu(null)}
|
||||
onOpen={handleOpenAsset}
|
||||
onOpen={handleOpenAssetInNewTab}
|
||||
onCopyLink={handleCopyAssetLink}
|
||||
onCopyPath={handleCopyAssetPath}
|
||||
onRename={handleRenameAsset}
|
||||
onMove={handleMoveAsset}
|
||||
onDelete={(assetIds) => void handleDeleteFromAssetContextMenu(assetIds, assetMenu.asset)}
|
||||
onDelete={() => void handleDeleteFromAssetContextMenu()}
|
||||
onDownload={handleDownloadAsset}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -12,11 +12,16 @@ import {
|
||||
type TreeShellPickerItem,
|
||||
} from "@/components/sidebar/tree-shell-dom-model";
|
||||
import type {
|
||||
FileTreeShellDeleteSelectionPayload,
|
||||
FileTreeShellExternalDropPayload,
|
||||
FileTreeShellInternalDropPayload,
|
||||
TreeShellHostMode,
|
||||
TreeShellPickerCommand,
|
||||
} from "@/components/sidebar/tree-shell-host";
|
||||
import {
|
||||
normalizeFileTreeSelectionForVisibleRows as normalizeVisibleFileTreeSelection,
|
||||
reduceFileTreeSelection,
|
||||
} from "@/lib/file-tree/selection";
|
||||
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -91,6 +96,7 @@ type TreeShellRustDomShellHostProps = {
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
@@ -201,6 +207,35 @@ function normalizeRuntimeState<T>(result: TreeShellRuntimeResult | null, mode: "
|
||||
return result.state.state as T;
|
||||
}
|
||||
|
||||
function areFileTreeSelectionsEqual(
|
||||
left: FileTreeRuntimeState["selection"],
|
||||
right: FileTreeRuntimeState["selection"],
|
||||
) {
|
||||
if (left.anchorRowId !== right.anchorRowId || left.focusedRowId !== right.focusedRowId) {
|
||||
return false;
|
||||
}
|
||||
if (left.selectedRowIds.length !== right.selectedRowIds.length) {
|
||||
return false;
|
||||
}
|
||||
return left.selectedRowIds.every((rowId, index) => rowId === right.selectedRowIds[index]);
|
||||
}
|
||||
|
||||
function toLocalSelectionState(selection: FileTreeRuntimeState["selection"]) {
|
||||
return {
|
||||
selectedRowIds: new Set(selection.selectedRowIds),
|
||||
anchorRowId: selection.anchorRowId,
|
||||
focusedRowId: selection.focusedRowId,
|
||||
};
|
||||
}
|
||||
|
||||
function fromLocalSelectionState(selection: ReturnType<typeof toLocalSelectionState>) {
|
||||
return {
|
||||
selectedRowIds: Array.from(selection.selectedRowIds),
|
||||
anchorRowId: selection.anchorRowId,
|
||||
focusedRowId: selection.focusedRowId,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTreeShellRuntimeWasm() {
|
||||
if (!treeShellRuntimeWasmPromise) {
|
||||
treeShellRuntimeWasmPromise = (async () => {
|
||||
@@ -271,6 +306,7 @@ export function TreeShellRustDomShellHost({
|
||||
onPageFocusChange,
|
||||
onFileTreeContextMenu,
|
||||
onFileTreeSelectionChange,
|
||||
onFileTreeDeleteSelection,
|
||||
onInternalDrop,
|
||||
onDropFiles,
|
||||
onAssetOpen,
|
||||
@@ -295,6 +331,9 @@ export function TreeShellRustDomShellHost({
|
||||
activeItemKey: activePickerItemKey,
|
||||
}));
|
||||
const pickerCommandSeqRef = useRef<number | null>(null);
|
||||
const fileTreeStateRef = useRef(fileTreeState);
|
||||
const fileTreeSelectionVersionRef = useRef(0);
|
||||
const fileTreeSelectionInitializedRef = useRef(false);
|
||||
|
||||
const pageItems = useMemo(() => {
|
||||
if (mode !== "page") return [];
|
||||
@@ -319,10 +358,7 @@ export function TreeShellRustDomShellHost({
|
||||
() => new Map(items.map((item) => [item.nodeId, item])),
|
||||
[items],
|
||||
);
|
||||
const filetreeItemByRowId = useMemo(
|
||||
() => new Map(fileTreeItems.map((item) => [toRowId(item), item])),
|
||||
[fileTreeItems],
|
||||
);
|
||||
const visibleFileTreeRowIds = useMemo(() => fileTreeItems.map(toRowId), [fileTreeItems]);
|
||||
const visiblePageItems = useMemo(() => {
|
||||
const expanded = new Set(pageState.expandedIds);
|
||||
const visible: TreeShellDomProjectionItem[] = [];
|
||||
@@ -426,8 +462,55 @@ export function TreeShellRustDomShellHost({
|
||||
[childrenByParent, pageItems, pageState, reduceRuntime, visiblePageItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fileTreeStateRef.current = fileTreeState;
|
||||
}, [fileTreeState]);
|
||||
|
||||
const updateFileTreeState = useCallback(
|
||||
(updater: (prev: FileTreeRuntimeState) => FileTreeRuntimeState) => {
|
||||
let nextStateSnapshot = fileTreeStateRef.current;
|
||||
setFileTreeState((prev) => {
|
||||
const nextState = updater(prev);
|
||||
nextStateSnapshot = nextState;
|
||||
fileTreeStateRef.current = nextState;
|
||||
return nextState;
|
||||
});
|
||||
return nextStateSnapshot;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const commitFileTreeSelection = useCallback(
|
||||
(nextSelection: FileTreeRuntimeState["selection"]) => {
|
||||
let changed = false;
|
||||
const nextState = updateFileTreeState((prev) => {
|
||||
if (areFileTreeSelectionsEqual(prev.selection, nextSelection)) {
|
||||
return prev;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...prev,
|
||||
selection: nextSelection,
|
||||
};
|
||||
});
|
||||
fileTreeSelectionInitializedRef.current = true;
|
||||
if (changed) {
|
||||
fileTreeSelectionVersionRef.current += 1;
|
||||
onFileTreeSelectionChange?.(nextSelection);
|
||||
}
|
||||
return nextState;
|
||||
},
|
||||
[onFileTreeSelectionChange, updateFileTreeState],
|
||||
);
|
||||
|
||||
const readFileTreeState = useCallback(() => fileTreeStateRef.current, []);
|
||||
|
||||
const reduceFileTreeAction = useCallback(
|
||||
async (action: Record<string, unknown>, stateSnapshot: FileTreeRuntimeState = fileTreeState) => {
|
||||
async (
|
||||
action: Record<string, unknown>,
|
||||
stateSnapshot: FileTreeRuntimeState = readFileTreeState(),
|
||||
options?: { selectionVersion?: number },
|
||||
) => {
|
||||
const requestId = `filetree-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const result = await reduceRuntime({
|
||||
mode: "fileTree",
|
||||
@@ -446,13 +529,20 @@ export function TreeShellRustDomShellHost({
|
||||
});
|
||||
const nextState = normalizeRuntimeState<FileTreeRuntimeState>(result, "fileTree");
|
||||
if (nextState) {
|
||||
setFileTreeState(nextState);
|
||||
onFileTreeSelectionChange?.(nextState.selection);
|
||||
return { result, state: nextState };
|
||||
const shouldApplySelection =
|
||||
options?.selectionVersion === undefined || options.selectionVersion === fileTreeSelectionVersionRef.current;
|
||||
const mergedState = updateFileTreeState((prev) => ({
|
||||
...nextState,
|
||||
selection: shouldApplySelection ? nextState.selection : prev.selection,
|
||||
}));
|
||||
if (shouldApplySelection && !areFileTreeSelectionsEqual(stateSnapshot.selection, mergedState.selection)) {
|
||||
onFileTreeSelectionChange?.(mergedState.selection);
|
||||
}
|
||||
return { result, state: mergedState };
|
||||
}
|
||||
return { result: null, state: stateSnapshot };
|
||||
},
|
||||
[fileTreeItems, fileTreeState, onFileTreeSelectionChange, reduceRuntime],
|
||||
[fileTreeItems, onFileTreeSelectionChange, readFileTreeState, reduceRuntime, updateFileTreeState],
|
||||
);
|
||||
|
||||
const reducePickerAction = useCallback(
|
||||
@@ -645,15 +735,25 @@ export function TreeShellRustDomShellHost({
|
||||
useEffect(() => {
|
||||
if (mode !== "filetree") return;
|
||||
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
|
||||
setFileTreeState({
|
||||
const shouldBackfillSelection =
|
||||
!fileTreeSelectionInitializedRef.current || readFileTreeState().selection.selectedRowIds.length === 0;
|
||||
if (shouldBackfillSelection) {
|
||||
commitFileTreeSelection(selection);
|
||||
}
|
||||
updateFileTreeState((prev) => ({
|
||||
...prev,
|
||||
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
|
||||
selection,
|
||||
dragRowIds: [],
|
||||
dragEffect: null,
|
||||
dropTargetRowId: null,
|
||||
});
|
||||
onFileTreeSelectionChange?.(selection);
|
||||
}, [activeDocumentId, mode, onFileTreeSelectionChange]);
|
||||
selection: shouldBackfillSelection ? selection : prev.selection,
|
||||
}));
|
||||
}, [activeDocumentId, commitFileTreeSelection, mode, readFileTreeState, updateFileTreeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "filetree") return;
|
||||
const normalizedSelection = fromLocalSelectionState(
|
||||
normalizeVisibleFileTreeSelection(toLocalSelectionState(readFileTreeState().selection), visibleFileTreeRowIds),
|
||||
);
|
||||
commitFileTreeSelection(normalizedSelection);
|
||||
}, [commitFileTreeSelection, mode, readFileTreeState, visibleFileTreeRowIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "picker") return;
|
||||
@@ -837,8 +937,17 @@ export function TreeShellRustDomShellHost({
|
||||
async (item: TreeShellDomProjectionItem, event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const rowId = toRowId(item);
|
||||
const { state } = await reduceFileTreeAction({ kind: "selectContextRow", rowId });
|
||||
const { result } = await reduceFileTreeAction({ kind: "contextMenuRow", rowId }, state);
|
||||
const state = commitFileTreeSelection(
|
||||
fromLocalSelectionState(
|
||||
reduceFileTreeSelection(toLocalSelectionState(readFileTreeState().selection), {
|
||||
type: "contextmenu",
|
||||
rowId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const selectionVersion = fileTreeSelectionVersionRef.current;
|
||||
void reduceFileTreeAction({ kind: "selectContextRow", rowId }, state, { selectionVersion });
|
||||
const { result } = await reduceFileTreeAction({ kind: "contextMenuRow", rowId }, state, { selectionVersion });
|
||||
applyFileTreeHostEvents(result?.hostEvents, {
|
||||
rowId,
|
||||
rowKind: toShellRowKind(item),
|
||||
@@ -846,13 +955,28 @@ export function TreeShellRustDomShellHost({
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
[applyFileTreeHostEvents, reduceFileTreeAction],
|
||||
[applyFileTreeHostEvents, commitFileTreeSelection, readFileTreeState, reduceFileTreeAction],
|
||||
);
|
||||
|
||||
const handleFileTreeSelect = useCallback(
|
||||
async (item: TreeShellDomProjectionItem, event: MouseEvent) => {
|
||||
(item: TreeShellDomProjectionItem, event: MouseEvent) => {
|
||||
const rowId = toRowId(item);
|
||||
await reduceFileTreeAction({
|
||||
const state = commitFileTreeSelection(
|
||||
fromLocalSelectionState(
|
||||
reduceFileTreeSelection(toLocalSelectionState(readFileTreeState().selection), {
|
||||
type: "click",
|
||||
rowId,
|
||||
visibleRowIds: visibleFileTreeRowIds,
|
||||
modifiers: {
|
||||
shiftKey: event.shiftKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const selectionVersion = fileTreeSelectionVersionRef.current;
|
||||
void reduceFileTreeAction({
|
||||
kind: "selectRow",
|
||||
rowId,
|
||||
modifiers: {
|
||||
@@ -860,9 +984,9 @@ export function TreeShellRustDomShellHost({
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
},
|
||||
});
|
||||
}, state, { selectionVersion });
|
||||
},
|
||||
[reduceFileTreeAction],
|
||||
[commitFileTreeSelection, readFileTreeState, reduceFileTreeAction, visibleFileTreeRowIds],
|
||||
);
|
||||
|
||||
const handleFileTreeKeyDown = useCallback(
|
||||
@@ -889,11 +1013,16 @@ export function TreeShellRustDomShellHost({
|
||||
if (!action) return;
|
||||
event.preventDefault();
|
||||
const rowId = toRowId(item);
|
||||
const currentFileTreeState = readFileTreeState();
|
||||
if (action.kind === "deleteSelection") {
|
||||
onFileTreeDeleteSelection?.(currentFileTreeState.selection);
|
||||
return;
|
||||
}
|
||||
const { result } = await reduceFileTreeAction(action, {
|
||||
...fileTreeState,
|
||||
...currentFileTreeState,
|
||||
selection: {
|
||||
...fileTreeState.selection,
|
||||
focusedRowId: fileTreeState.selection.focusedRowId ?? rowId,
|
||||
...currentFileTreeState.selection,
|
||||
focusedRowId: currentFileTreeState.selection.focusedRowId ?? rowId,
|
||||
},
|
||||
});
|
||||
applyFileTreeHostEvents(result?.hostEvents, {
|
||||
@@ -901,7 +1030,7 @@ export function TreeShellRustDomShellHost({
|
||||
rowKind: toShellRowKind(item),
|
||||
});
|
||||
},
|
||||
[applyFileTreeHostEvents, fileTreeState, reduceFileTreeAction],
|
||||
[applyFileTreeHostEvents, onFileTreeDeleteSelection, readFileTreeState, reduceFileTreeAction],
|
||||
);
|
||||
|
||||
const handleFileTreeDrop = useCallback(
|
||||
@@ -1095,7 +1224,11 @@ export function TreeShellRustDomShellHost({
|
||||
data-testid="filetree-action-menu"
|
||||
aria-label="更多操作"
|
||||
className="rounded-md px-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
onClick={(event) => void handleFileTreeContextMenu(item, event)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleFileTreeContextMenu(item, event);
|
||||
}}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TreeShellRustDomShellHost } from "./tree-shell-dom-host";
|
||||
import { TreeShellHost } from "./tree-shell-host";
|
||||
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type PendingRuntimeRequest = {
|
||||
request: Record<string, unknown>;
|
||||
resolve: (runtimeResult?: Record<string, unknown> | null) => void;
|
||||
};
|
||||
|
||||
describe("tree-shell-host", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let previousLegacyFlag: string | undefined;
|
||||
let originalFetch: typeof fetch | undefined;
|
||||
let pendingRuntimeRequests: PendingRuntimeRequest[];
|
||||
|
||||
beforeEach(() => {
|
||||
previousLegacyFlag = process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
|
||||
@@ -16,13 +25,35 @@ describe("tree-shell-host", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
originalFetch = global.fetch;
|
||||
pendingRuntimeRequests = [];
|
||||
global.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
return new Promise((resolve) => {
|
||||
pendingRuntimeRequests.push({
|
||||
request,
|
||||
resolve: (runtimeResult = null) => {
|
||||
resolve({
|
||||
ok: true,
|
||||
json: async () => runtimeResult,
|
||||
} as Response);
|
||||
},
|
||||
});
|
||||
});
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pendingRuntimeRequests.splice(0).forEach(({ resolve }) => resolve(null));
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
if (originalFetch) {
|
||||
global.fetch = originalFetch;
|
||||
} else {
|
||||
delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch;
|
||||
}
|
||||
if (previousLegacyFlag === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
|
||||
} else {
|
||||
@@ -30,6 +61,109 @@ describe("tree-shell-host", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const fileTreeItems: KernelFileTreeProjectionItem[] = [
|
||||
{
|
||||
projectionKind: "file_tree",
|
||||
rowId: "index:doc_1",
|
||||
rowKind: "index",
|
||||
nodeId: "doc_1:index",
|
||||
parentNodeId: null,
|
||||
title: "Doc 1 索引",
|
||||
depth: 0,
|
||||
childCount: 0,
|
||||
position: 0,
|
||||
expandedByDefault: false,
|
||||
iconHint: "page",
|
||||
capabilities: ["select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
},
|
||||
{
|
||||
projectionKind: "file_tree",
|
||||
rowId: "index:doc_2",
|
||||
rowKind: "index",
|
||||
nodeId: "doc_2:index",
|
||||
parentNodeId: null,
|
||||
title: "Doc 2 索引",
|
||||
depth: 0,
|
||||
childCount: 0,
|
||||
position: 1,
|
||||
expandedByDefault: false,
|
||||
iconHint: "page",
|
||||
capabilities: ["select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_2",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
},
|
||||
{
|
||||
projectionKind: "file_tree",
|
||||
rowId: "index:doc_3",
|
||||
rowKind: "index",
|
||||
nodeId: "doc_3:index",
|
||||
parentNodeId: null,
|
||||
title: "Doc 3 索引",
|
||||
depth: 0,
|
||||
childCount: 0,
|
||||
position: 2,
|
||||
expandedByDefault: false,
|
||||
iconHint: "page",
|
||||
capabilities: ["select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_3",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function queryFileTreeRow(rowId: string) {
|
||||
return container.querySelector(`[data-row-id="${rowId}"]`) as HTMLElement | null;
|
||||
}
|
||||
|
||||
function expectSelectedRowIds(rowIds: string[]) {
|
||||
const selected = Array.from(container.querySelectorAll('[data-shell-mode="filetree"][data-selected="true"]'))
|
||||
.map((element) => element.getAttribute("data-row-id"))
|
||||
.filter((rowId): rowId is string => Boolean(rowId));
|
||||
expect(selected).toEqual(rowIds);
|
||||
}
|
||||
|
||||
function renderDomHost(input: {
|
||||
activeDocumentId?: string | null;
|
||||
onFileTreeDeleteSelection?: (payload: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
} = {}) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TreeShellRustDomShellHost
|
||||
mode="filetree"
|
||||
surfaceTestId="sidebar-file-tree-shell"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId={input.activeDocumentId ?? null}
|
||||
inlineFileTreeItems={fileTreeItems}
|
||||
onFileTreeDeleteSelection={input.onFileTreeDeleteSelection}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function flushRuntimeDispatch() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function renderHost() {
|
||||
act(() => {
|
||||
root.render(
|
||||
@@ -79,4 +213,158 @@ describe("tree-shell-host", () => {
|
||||
expect(iframe?.getAttribute("data-tree-browser-bridge")).toBe("iframe_srcdoc");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
|
||||
});
|
||||
|
||||
it("filetree DOM host 应先本地提交 Ctrl/Shift selection,再异步等 runtime 对账", async () => {
|
||||
renderDomHost();
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2"]);
|
||||
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
});
|
||||
|
||||
it("旧 runtime selection 回写不应覆盖更新后的多选,Delete 应使用当前稳定 selection", async () => {
|
||||
const handleDeleteSelection = vi.fn();
|
||||
renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection });
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
act(() => {
|
||||
pendingRuntimeRequests[0]?.resolve({
|
||||
requestId: String(pendingRuntimeRequests[0]?.request.requestId ?? "filetree-stale"),
|
||||
mode: "fileTree",
|
||||
state: {
|
||||
mode: "fileTree",
|
||||
state: {
|
||||
activeRowId: null,
|
||||
selection: {
|
||||
selectedRowIds: ["index:doc_2"],
|
||||
anchorRowId: "index:doc_2",
|
||||
focusedRowId: "index:doc_2",
|
||||
},
|
||||
dragRowIds: [],
|
||||
dragEffect: null,
|
||||
dropTargetRowId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
expect(handleDeleteSelection).toHaveBeenCalledTimes(1);
|
||||
expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({
|
||||
selectedRowIds: ["index:doc_2", "index:doc_3"],
|
||||
anchorRowId: "index:doc_3",
|
||||
focusedRowId: "index:doc_3",
|
||||
});
|
||||
});
|
||||
|
||||
it("Backspace 也应复用当前稳定多选并触发删除回调", async () => {
|
||||
const handleDeleteSelection = vi.fn();
|
||||
renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection });
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
expect(handleDeleteSelection).toHaveBeenCalledTimes(1);
|
||||
expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({
|
||||
selectedRowIds: ["index:doc_2", "index:doc_3"],
|
||||
anchorRowId: "index:doc_3",
|
||||
focusedRowId: "index:doc_3",
|
||||
});
|
||||
});
|
||||
|
||||
it("点击行内更多操作时不应因为事件冒泡把多选压成单选", async () => {
|
||||
renderDomHost();
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
const row3Menu = container.querySelector(
|
||||
'[data-row-id="index:doc_3"] [data-testid="filetree-action-menu"]',
|
||||
) as HTMLElement | null;
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
expect(row3Menu).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
|
||||
act(() => {
|
||||
row3Menu?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
});
|
||||
|
||||
it("activeDocumentId 变化时,已有 filetree selection 不应被无条件重置", () => {
|
||||
renderDomHost();
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
const row3 = queryFileTreeRow("index:doc_3");
|
||||
expect(row2).not.toBeNull();
|
||||
expect(row3).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
act(() => {
|
||||
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
});
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
|
||||
renderDomHost({ activeDocumentId: "doc_1" });
|
||||
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,12 @@ export type FileTreeShellExternalDropPayload = {
|
||||
files: FileList | File[];
|
||||
};
|
||||
|
||||
export type FileTreeShellDeleteSelectionPayload = {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
};
|
||||
|
||||
type TreeShellHostProps = {
|
||||
mode: TreeShellHostMode;
|
||||
surfaceTestId: string;
|
||||
@@ -79,6 +85,7 @@ type TreeShellHostProps = {
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
@@ -114,6 +121,7 @@ export function TreeShellHost({
|
||||
onPageFocusChange,
|
||||
onFileTreeContextMenu,
|
||||
onFileTreeSelectionChange,
|
||||
onFileTreeDeleteSelection,
|
||||
onInternalDrop,
|
||||
onDropFiles,
|
||||
onAssetOpen,
|
||||
@@ -181,6 +189,7 @@ export function TreeShellHost({
|
||||
onPageFocusChange={onPageFocusChange}
|
||||
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||
onFileTreeDeleteSelection={onFileTreeDeleteSelection}
|
||||
onInternalDrop={onInternalDrop}
|
||||
onDropFiles={onDropFiles}
|
||||
onAssetOpen={onAssetOpen}
|
||||
@@ -211,6 +220,7 @@ export function TreeShellHost({
|
||||
onPageFocusChange={onPageFocusChange}
|
||||
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||
onFileTreeDeleteSelection={onFileTreeDeleteSelection}
|
||||
onInternalDrop={onInternalDrop}
|
||||
onDropFiles={onDropFiles}
|
||||
onAssetOpen={onAssetOpen}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { DragEvent, MouseEvent } from "react";
|
||||
import {
|
||||
TreeShellHost,
|
||||
type FileTreeShellDeleteSelectionPayload,
|
||||
type FileTreeShellExternalDropPayload,
|
||||
type FileTreeShellInternalDropPayload,
|
||||
type TreeShellPickerCommand,
|
||||
@@ -71,6 +72,7 @@ type SidebarFileTreeSurfaceProps = {
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
};
|
||||
@@ -153,6 +155,7 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
||||
onPageFocusChange={props.mode === "page" ? props.onPageFocusChange : undefined}
|
||||
onFileTreeContextMenu={props.mode === "filetree" ? props.onFileTreeContextMenu : undefined}
|
||||
onFileTreeSelectionChange={props.mode === "filetree" ? props.onFileTreeSelectionChange : undefined}
|
||||
onFileTreeDeleteSelection={props.mode === "filetree" ? props.onFileTreeDeleteSelection : undefined}
|
||||
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
|
||||
onDropFiles={props.mode === "filetree" ? props.onDropFiles : undefined}
|
||||
onAssetOpen={props.mode === "filetree" ? props.onAssetOpen : undefined}
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("usePreferredSidebarSnapshot", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("tree stream live 时即使 query 更新也应继续优先使用 stream 快照", async () => {
|
||||
it("tree stream 仍停在 initial 时,query 新快照不应被旧 stream 压住", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const refreshedQuery = buildSidebarData([
|
||||
@@ -81,6 +81,12 @@ describe("usePreferredSidebarSnapshot", () => {
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
const caughtUpTreeStream = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
@@ -112,10 +118,29 @@ describe("usePreferredSidebarSnapshot", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "query",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={refreshedQuery}
|
||||
treeStreamData={caughtUpTreeStream}
|
||||
treeStreamStatus="live"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "tree_stream",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,8 +20,16 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
);
|
||||
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
|
||||
const streamIsPreferred = input.treeStreamStatus !== "fallback";
|
||||
const queryHasFreshSnapshot =
|
||||
input.sidebarQueryData != null &&
|
||||
querySyncKey != null &&
|
||||
querySyncKey !== initialSyncKey &&
|
||||
treeStreamSyncKey === initialSyncKey;
|
||||
|
||||
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
||||
if (queryHasFreshSnapshot) {
|
||||
return "query";
|
||||
}
|
||||
if (input.treeStreamData && streamIsPreferred) {
|
||||
return "tree_stream";
|
||||
}
|
||||
@@ -32,6 +40,7 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
}, [
|
||||
input.sidebarQueryData,
|
||||
input.treeStreamData,
|
||||
queryHasFreshSnapshot,
|
||||
streamIsPreferred,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildVisibleDocumentIds } from "../../../convex/_utils/documentVisibility";
|
||||
|
||||
describe("buildVisibleDocumentIds", () => {
|
||||
const docs = [
|
||||
{ id: "owner-root", user_id: "u1", parent_id: null, access_scope: "private", deleted_at: null },
|
||||
{ id: "public-root", user_id: "u2", parent_id: null, access_scope: "public", deleted_at: null },
|
||||
{ id: "shared-root", user_id: "u2", parent_id: null, access_scope: "private", deleted_at: null },
|
||||
{ id: "shared-child", user_id: "u2", parent_id: "shared-root", access_scope: "private", deleted_at: null },
|
||||
{ id: "group-root", user_id: "u3", parent_id: null, access_scope: "private", deleted_at: null },
|
||||
{ id: "group-child", user_id: "u3", parent_id: "group-root", access_scope: "private", deleted_at: null },
|
||||
{ id: "deleted-public", user_id: "u4", parent_id: null, access_scope: "public", deleted_at: "2026-05-12T00:00:00.000Z" },
|
||||
] as const;
|
||||
|
||||
it("允许 owner、public、直接分享与继承分享的文档可见", () => {
|
||||
const visible = buildVisibleDocumentIds({
|
||||
userId: "u1",
|
||||
docs,
|
||||
directShares: [{ document_id: "shared-root", include_descendants: true }],
|
||||
directGroupShares: [],
|
||||
});
|
||||
|
||||
expect(Array.from(visible).sort()).toEqual([
|
||||
"owner-root",
|
||||
"public-root",
|
||||
"shared-child",
|
||||
"shared-root",
|
||||
]);
|
||||
});
|
||||
|
||||
it("允许群组分享向下继承,但不会包含已删除文档", () => {
|
||||
const visible = buildVisibleDocumentIds({
|
||||
userId: "u9",
|
||||
docs,
|
||||
directShares: [],
|
||||
directGroupShares: [{ document_id: "group-root", include_descendants: true }],
|
||||
includeDeletedDocuments: false,
|
||||
});
|
||||
|
||||
expect(Array.from(visible).sort()).toEqual([
|
||||
"group-child",
|
||||
"group-root",
|
||||
"public-root",
|
||||
]);
|
||||
expect(visible.has("deleted-public")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,58 @@ describe("buildDocumentSavePayload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("保留 mindmap 引用骨架,不把图数据保存成文档块真源", () => {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
tiptapDocument: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
blockId: "mind_block_1",
|
||||
mindmapId: "mindmap_1",
|
||||
mnoteBlockType: "mindmap",
|
||||
projectionVersion: 1,
|
||||
rootNodeId: "root",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.editorDocument).toEqual({
|
||||
documentId: "doc_1",
|
||||
rootBlockIds: ["mind_block_1"],
|
||||
blocks: [
|
||||
{
|
||||
blockId: "mind_block_1",
|
||||
blockType: "mindmap",
|
||||
props: {
|
||||
mindmapId: "mindmap_1",
|
||||
rootNodeId: "root",
|
||||
projectionVersion: 1,
|
||||
},
|
||||
contentNodes: [],
|
||||
childBlockIds: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(payload.content).toEqual([
|
||||
{
|
||||
id: "mind_block_1",
|
||||
type: "mindmap",
|
||||
props: {
|
||||
mindmapId: "mindmap_1",
|
||||
rootNodeId: "root",
|
||||
projectionVersion: 1,
|
||||
},
|
||||
content: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("保留正文快照采集元数据", () => {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
|
||||
@@ -219,7 +219,7 @@ describe("tiptap-content-converter", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("保留 slash 插入的 mindmap placeholder 骨架为 mindmap legacy block", () => {
|
||||
it("保留 mindmap 引用骨架为 mindmap legacy block,不把图数据写回文档块", () => {
|
||||
const tiptapDoc = {
|
||||
type: "doc" as const,
|
||||
content: [
|
||||
@@ -228,6 +228,9 @@ describe("tiptap-content-converter", () => {
|
||||
attrs: {
|
||||
blockId: "mind_1",
|
||||
mnoteBlockType: "mindmap",
|
||||
mindmapId: "mindmap_1",
|
||||
rootNodeId: "root",
|
||||
projectionVersion: 1,
|
||||
mnoteMindmapData: {
|
||||
data: { uid: "root", text: "KMIND", generalization: { text: "概要" } },
|
||||
children: [
|
||||
@@ -251,19 +254,48 @@ describe("tiptap-content-converter", () => {
|
||||
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: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
mindmapId: "mindmap_1",
|
||||
rootNodeId: "root",
|
||||
projectionVersion: 1,
|
||||
},
|
||||
content: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("读取旧 mindmap block 时只迁移引用字段,丢弃 props.data 作为块真源", () => {
|
||||
const tiptapDoc = tiptapDocFromBlocks([
|
||||
{
|
||||
id: "mind_legacy",
|
||||
type: "mindmap",
|
||||
props: {
|
||||
data: { id: "mindmap_from_legacy_data", rootNodeId: "root_from_data" },
|
||||
},
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(tiptapDoc).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
blockId: "mind_legacy",
|
||||
mnoteBlockType: "mindmap",
|
||||
mindmapId: "mindmap_from_legacy_data",
|
||||
rootNodeId: "root_from_data",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(blocksFromTiptapDoc(tiptapDoc)).toEqual([
|
||||
{
|
||||
id: "mind_legacy",
|
||||
type: "mindmap",
|
||||
props: {
|
||||
mindmapId: "mindmap_from_legacy_data",
|
||||
rootNodeId: "root_from_data",
|
||||
},
|
||||
content: "",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -47,7 +47,9 @@ export type EditorBlock = {
|
||||
headingLevel?: number | null;
|
||||
checked?: boolean | null;
|
||||
language?: string | null;
|
||||
data?: unknown;
|
||||
mindmapId?: string | null;
|
||||
rootNodeId?: string | null;
|
||||
projectionVersion?: number | null;
|
||||
};
|
||||
contentNodes?: EditorContentNode[];
|
||||
childBlockIds?: string[];
|
||||
@@ -178,6 +180,39 @@ function normalizeBlockId(value: unknown, fallback: string): string {
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown): string | null {
|
||||
const raw = typeof value === "string" ? value.trim() : "";
|
||||
return raw || null;
|
||||
}
|
||||
|
||||
function mindmapReferenceProps(
|
||||
attrsOrProps: Record<string, unknown> | undefined,
|
||||
fallbackMindmapId: string,
|
||||
): NonNullable<EditorBlock["props"]> {
|
||||
const data = attrsOrProps?.data && typeof attrsOrProps.data === "object"
|
||||
? attrsOrProps.data as Record<string, unknown>
|
||||
: {};
|
||||
const mindmapId =
|
||||
optionalText(attrsOrProps?.mindmapId) ??
|
||||
optionalText(attrsOrProps?.mindmap_id) ??
|
||||
optionalText(data.mindmapId) ??
|
||||
optionalText(data.mindmap_id) ??
|
||||
optionalText(data.id) ??
|
||||
fallbackMindmapId;
|
||||
const rootNodeId =
|
||||
optionalText(attrsOrProps?.rootNodeId) ??
|
||||
optionalText(attrsOrProps?.root_node_id) ??
|
||||
optionalText(data.rootNodeId) ??
|
||||
optionalText(data.root_node_id) ??
|
||||
"root";
|
||||
const projectionVersion = Number(attrsOrProps?.projectionVersion ?? attrsOrProps?.projection_version);
|
||||
return {
|
||||
mindmapId,
|
||||
rootNodeId,
|
||||
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEditorContentNodes(input: unknown): EditorContentNode[] {
|
||||
if (Array.isArray(input)) {
|
||||
const nodes = input.flatMap((item) => {
|
||||
@@ -252,7 +287,7 @@ function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBloc
|
||||
return {
|
||||
blockId,
|
||||
blockType: "mindmap",
|
||||
props: { data: props.data ?? block.content },
|
||||
props: mindmapReferenceProps(props, blockId),
|
||||
contentNodes: [],
|
||||
childBlockIds: [],
|
||||
};
|
||||
@@ -378,9 +413,8 @@ function blockToTiptapNode(block: EditorBlock): TiptapNode {
|
||||
attrs: {
|
||||
...commonAttrs,
|
||||
mnoteBlockType: "mindmap",
|
||||
mnoteMindmapData: block.props?.data ?? null,
|
||||
...mindmapReferenceProps(block.props, block.blockId),
|
||||
},
|
||||
content: textNodesToInline(block.contentNodes),
|
||||
};
|
||||
case "paragraph":
|
||||
default:
|
||||
@@ -433,7 +467,7 @@ function tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null
|
||||
return {
|
||||
blockId,
|
||||
blockType: "mindmap",
|
||||
props: { data: node.attrs.mnoteMindmapData },
|
||||
props: mindmapReferenceProps(node.attrs, blockId),
|
||||
contentNodes: [],
|
||||
childBlockIds: [],
|
||||
};
|
||||
@@ -623,7 +657,7 @@ export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocumen
|
||||
: block.blockType === "code_block"
|
||||
? { language: block.props?.language ?? null }
|
||||
: block.blockType === "mindmap"
|
||||
? { data: block.props?.data ?? null }
|
||||
? mindmapReferenceProps(block.props, block.blockId)
|
||||
: undefined,
|
||||
content: block.blockType === "mindmap" ? "" : legacyContentFromNodes(block.contentNodes),
|
||||
}));
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createMindmapCommandApplyEndpoint,
|
||||
executeMindmapCommandApply,
|
||||
executeMindmapCommandApplyAndRefreshProjection,
|
||||
executeMindmapDataPutAndRefreshProjection,
|
||||
isMindmapAdapterProjection,
|
||||
requestMindmapAdapterProjection,
|
||||
} from "./leptos-mindmap-adapter";
|
||||
@@ -87,6 +88,23 @@ describe("leptos-mindmap adapter projection", () => {
|
||||
commands: [{ type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" }],
|
||||
fetcher,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "command_failed", status: 409 });
|
||||
).rejects.toMatchObject({ code: "command_failed", status: 409, message: "command_failed:409:revision_conflict" });
|
||||
});
|
||||
|
||||
it("data put 失败时透出 route 错误细节", async () => {
|
||||
const fetcher = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ error: "mindmap_put_failed", details: ["missing_root_uid"] }),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
executeMindmapDataPutAndRefreshProjection({
|
||||
documentId: "doc_1",
|
||||
mindmapId: "mind_1",
|
||||
data: { data: { text: "中心主题" }, children: [] },
|
||||
fetcher,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "command_failed", status: 400, message: "command_failed:400:mindmap_put_failed" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,15 @@ export type MindmapCommandApplyInput = {
|
||||
fetcher?: typeof fetch;
|
||||
};
|
||||
|
||||
export type MindmapDataPutInput = {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
data: unknown;
|
||||
projectionEndpoint?: string;
|
||||
endpoint?: string;
|
||||
fetcher?: typeof fetch;
|
||||
};
|
||||
|
||||
export type MindmapCommandApplyResult = {
|
||||
ok: true;
|
||||
kernelRevision: number | null;
|
||||
@@ -59,6 +68,32 @@ export class MindmapCommandBridgeError extends Error {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const readErrorDetailFromPayload = (raw: unknown): string => {
|
||||
if (!isRecord(raw)) return "";
|
||||
const detail = [raw.error, raw.message, raw.details]
|
||||
.map((value) => {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (Array.isArray(value) && value.length > 0) return value.join("|");
|
||||
return "";
|
||||
})
|
||||
.find(Boolean);
|
||||
return detail ? `:${detail}` : "";
|
||||
};
|
||||
|
||||
const readErrorDetail = async (response: Response): Promise<string> => {
|
||||
if (typeof response.text !== "function") {
|
||||
const raw = (await response.json().catch(() => null)) as unknown;
|
||||
return readErrorDetailFromPayload(raw);
|
||||
}
|
||||
const rawText = await response.text().catch(() => "");
|
||||
if (!rawText.trim()) return "";
|
||||
try {
|
||||
return readErrorDetailFromPayload(JSON.parse(rawText) as unknown) || `:${rawText.trim()}`;
|
||||
} catch {
|
||||
return `:${rawText.trim()}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const isMindmapAdapterProjection = (value: unknown): value is MindmapAdapterProjection => {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
@@ -122,23 +157,29 @@ export const executeMindmapCommandApply = async (
|
||||
}
|
||||
const fetcher = input.fetcher ?? fetch;
|
||||
const endpoint = input.endpoint ?? createMindmapCommandApplyEndpoint(input.documentId, input.mindmapId);
|
||||
const requestBody = JSON.stringify({
|
||||
commandName: "mindmap.command.apply",
|
||||
documentId: input.documentId,
|
||||
mindmapId: input.mindmapId,
|
||||
commands: input.commands,
|
||||
projectionRevision: input.projectionRevision ?? null,
|
||||
});
|
||||
const response = await fetcher(endpoint, {
|
||||
method: "POST",
|
||||
keepalive: requestBody.length <= 60_000,
|
||||
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,
|
||||
}),
|
||||
body: requestBody,
|
||||
});
|
||||
const raw = (await response.json().catch(() => null)) as unknown;
|
||||
if (!response.ok) {
|
||||
throw new MindmapCommandBridgeError(`command_failed:${response.status}`, response.status);
|
||||
const detail = readErrorDetailFromPayload(raw);
|
||||
throw new MindmapCommandBridgeError(
|
||||
`command_failed:${response.status}${detail}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
@@ -159,6 +200,35 @@ export const executeMindmapCommandApplyAndRefreshProjection = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const executeMindmapDataPutAndRefreshProjection = async (
|
||||
input: MindmapDataPutInput,
|
||||
): Promise<MindmapAdapterProjection> => {
|
||||
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({
|
||||
data: input.data,
|
||||
createOnly: false,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await readErrorDetail(response);
|
||||
throw new MindmapCommandBridgeError(`command_failed:${response.status}${detail}`, response.status);
|
||||
}
|
||||
await response.json().catch(() => null);
|
||||
return requestMindmapAdapterProjection({
|
||||
documentId: input.documentId,
|
||||
mindmapId: input.mindmapId,
|
||||
endpoint: input.projectionEndpoint,
|
||||
fetcher,
|
||||
});
|
||||
};
|
||||
|
||||
export const createLeptosMindmapAdapter = async (
|
||||
input: LeptosMindmapAdapterOptions,
|
||||
): Promise<SimpleMindMapBridge> => {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("mindmap action map", () => {
|
||||
},
|
||||
});
|
||||
expect(mapMindmapActionToCommand({ actionId: "deleteNode", mindmapId: "mind_1", activeNodeId: "node_1" })).toEqual({
|
||||
runtimeCommand: "DELETE_NODE",
|
||||
runtimeCommand: "REMOVE_NODE",
|
||||
command: { type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" },
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,21 @@ describe("mindmap action map", () => {
|
||||
expect(getMindmapActionMapping("zoomIn")).toMatchObject({ target: "localView", runtimeMethod: "zoomIn" });
|
||||
expect(getMindmapActionMapping("zoomOut")).toMatchObject({ target: "localView", runtimeMethod: "zoomOut" });
|
||||
expect(getMindmapActionMapping("fitView")).toMatchObject({ target: "localView", runtimeMethod: "fitView" });
|
||||
expect(getMindmapActionMapping("fullscreenCanvas")).toMatchObject({ target: "localView", requiresActiveNode: false });
|
||||
expect(getMindmapActionMapping("fullscreenPage")).toMatchObject({ target: "localView", requiresActiveNode: false });
|
||||
expect(getMindmapActionMapping("exitFullscreen")).toMatchObject({ target: "localView", requiresActiveNode: false });
|
||||
expect(getMindmapActionMapping("showMenu")).toMatchObject({ target: "localView", requiresActiveNode: false });
|
||||
});
|
||||
|
||||
it("全屏动作不产生 kernel command,readonly 下仍可使用", () => {
|
||||
expect(mapMindmapActionToCommand({ actionId: "fullscreenCanvas", mindmapId: "mind_1" })).toEqual({});
|
||||
expect(mapMindmapActionToCommand({ actionId: "fullscreenPage", mindmapId: "mind_1" })).toEqual({});
|
||||
expect(mapMindmapActionToCommand({ actionId: "exitFullscreen", mindmapId: "mind_1" })).toEqual({});
|
||||
expect(mapMindmapActionToCommand({ actionId: "showMenu", mindmapId: "mind_1" })).toEqual({});
|
||||
expect(getMindmapActionMapping("fullscreenCanvas")).toMatchObject({ readonlyAllowed: true });
|
||||
expect(getMindmapActionMapping("fullscreenPage")).toMatchObject({ readonlyAllowed: true });
|
||||
expect(getMindmapActionMapping("exitFullscreen")).toMatchObject({ readonlyAllowed: true });
|
||||
expect(getMindmapActionMapping("showMenu")).toMatchObject({ readonlyAllowed: true });
|
||||
});
|
||||
|
||||
it("把主题、结构和扩展字段映射到 kernel/compat", () => {
|
||||
|
||||
@@ -46,11 +46,11 @@ const nodeDataPath = (field: string) => (activeNodeId: string | null): string |
|
||||
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: "editNode", target: "localView", 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: "deleteNode", target: "runtimeCommand", runtimeCommand: "REMOVE_NODE", requiresActiveNode: true, readonlyAllowed: false },
|
||||
{ actionId: "summary", target: "runtimeCommand", 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 },
|
||||
@@ -67,7 +67,11 @@ const mappings: MindmapActionMapping[] = [
|
||||
{ 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: "fullscreenCanvas", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
|
||||
{ actionId: "fullscreenPage", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
|
||||
{ actionId: "exitFullscreen", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
|
||||
{ actionId: "search", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
|
||||
{ actionId: "showMenu", 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 },
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyMindmapCommandsLocally,
|
||||
applyMetadataOnlyMindmapCommands,
|
||||
isLocallyApplicableMindmapCommandSet,
|
||||
isMetadataOnlyMindmapCommandSet,
|
||||
} from "./mindmap-command-apply-local";
|
||||
|
||||
describe("mindmap command apply local", () => {
|
||||
it("识别 metadata-only 命令集合", () => {
|
||||
expect(
|
||||
isMetadataOnlyMindmapCommandSet([
|
||||
{ type: "setLayout", mindmapId: "mind_1", layout: "mindMap" },
|
||||
{ type: "setTheme", mindmapId: "mind_1", theme: "dark" },
|
||||
{ type: "patchView", mindmapId: "mind_1", patch: { state: { scale: 1.2 } } },
|
||||
{ type: "compatPayloadPatch", mindmapId: "mind_1", path: "root.data.fillColor", value: "#dbeafe" },
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMetadataOnlyMindmapCommandSet([
|
||||
{ type: "insertChild", mindmapId: "mind_1", parentNodeId: "root", node: { text: "新节点" } },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("把 layout/theme/view/compat patch 合并回完整 mindmap blob", () => {
|
||||
const result = applyMetadataOnlyMindmapCommands(
|
||||
{
|
||||
data: { uid: "root", text: "KMIND" },
|
||||
children: [{ data: { uid: "node_1", text: "子节点" }, children: [] }],
|
||||
layout: "logicalStructure",
|
||||
theme: "classic",
|
||||
themeConfig: { lineColor: "#60a5fa" },
|
||||
view: { state: { scale: 1 } },
|
||||
compatPayload: { source: "compat-blob" },
|
||||
},
|
||||
[
|
||||
{ type: "setLayout", mindmapId: "mind_1", layout: "mindMap" },
|
||||
{ type: "setTheme", mindmapId: "mind_1", theme: "dark" },
|
||||
{ type: "patchView", mindmapId: "mind_1", patch: { state: { scale: 1.25, x: 10 } } },
|
||||
{ type: "compatPayloadPatch", mindmapId: "mind_1", path: "nodes.node_1.data.fillColor", value: "#dbeafe" },
|
||||
{ type: "compatPayloadPatch", mindmapId: "mind_1", path: "style.map.backgroundColor", value: "#0f172a" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.applied).toBe(5);
|
||||
expect(result.data.layout).toBe("mindMap");
|
||||
expect(result.data.theme).toBe("dark");
|
||||
expect(result.data.view).toEqual({ state: { scale: 1.25, x: 10 } });
|
||||
expect(result.data.children[0].data.fillColor).toBe("#dbeafe");
|
||||
expect(result.data.compatPayload).toEqual({
|
||||
source: "compat-blob",
|
||||
style: {
|
||||
map: {
|
||||
backgroundColor: "#0f172a",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("识别可本地应用的结构命令集合", () => {
|
||||
expect(
|
||||
isLocallyApplicableMindmapCommandSet([
|
||||
{ type: "updateText", mindmapId: "mind_1", nodeId: "node_1", text: "改名" },
|
||||
{ type: "insertChild", mindmapId: "mind_1", parentNodeId: "node_1", node: { uid: "node_2", text: "子节点" } },
|
||||
{ type: "insertSiblingAfter", mindmapId: "mind_1", targetNodeId: "node_1", node: { uid: "node_3", text: "同级" } },
|
||||
{ type: "deleteNode", mindmapId: "mind_1", nodeId: "node_3" },
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isLocallyApplicableMindmapCommandSet([
|
||||
{ type: "moveNode", mindmapId: "mind_1", nodeId: "node_1", newParentNodeId: "root" },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("可本地应用 update/insert/delete 到 mindmap tree,避免每次都整块重挂载", () => {
|
||||
const result = applyMindmapCommandsLocally(
|
||||
{
|
||||
data: { uid: "root", text: "KMIND" },
|
||||
children: [
|
||||
{
|
||||
data: { uid: "node_1", text: "节点 1" },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
data: { uid: "node_2", text: "节点 2" },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
layout: "logicalStructure",
|
||||
theme: "default",
|
||||
},
|
||||
[
|
||||
{ type: "updateText", mindmapId: "mind_1", nodeId: "node_1", text: "节点 1A" },
|
||||
{
|
||||
type: "insertChild",
|
||||
mindmapId: "mind_1",
|
||||
parentNodeId: "node_1",
|
||||
node: { uid: "node_1_child", text: "子节点 A" },
|
||||
},
|
||||
{
|
||||
type: "insertSiblingAfter",
|
||||
mindmapId: "mind_1",
|
||||
targetNodeId: "node_1",
|
||||
node: { uid: "node_1_sibling", text: "同级 A" },
|
||||
},
|
||||
{ type: "deleteNode", mindmapId: "mind_1", nodeId: "node_2" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.applied).toBe(4);
|
||||
expect(result.data.children).toHaveLength(2);
|
||||
expect(result.data.children[0].data.text).toBe("节点 1A");
|
||||
expect(result.data.children[0].children[0].data.uid).toBe("node_1_child");
|
||||
expect(result.data.children[1].data.uid).toBe("node_1_sibling");
|
||||
expect(result.data.children[1].data.text).toBe("同级 A");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { MindmapCompatPayloadPatch, MindmapKernelCommand } from "./mindmap-command-diff";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
type MetadataOnlyMindmapCommand =
|
||||
| Extract<MindmapKernelCommand, { type: "setLayout" | "setTheme" | "patchView" }>
|
||||
| MindmapCompatPayloadPatch;
|
||||
|
||||
type LocallyApplicableMindmapCommand =
|
||||
| Exclude<MindmapKernelCommand, { type: "moveNode" }>
|
||||
| MindmapCompatPayloadPatch;
|
||||
|
||||
type ApplyMetadataOnlyMindmapCommandsResult = {
|
||||
applied: number;
|
||||
data: Record<string, unknown>;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
type ApplyMindmapCommandsLocallyResult = ApplyMetadataOnlyMindmapCommandsResult;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
const ensureMindmapBlob = (value: unknown): Record<string, unknown> => {
|
||||
const next = isRecord(value) ? cloneJson(value) : cloneJson(defaultMindmapData);
|
||||
if (!isRecord(next.data)) next.data = { text: "中心主题" };
|
||||
if (!Array.isArray(next.children)) next.children = [];
|
||||
return next;
|
||||
};
|
||||
|
||||
const mergeJsonObject = (base: unknown, patch: unknown): unknown => {
|
||||
if (!isRecord(base)) return cloneJson(patch);
|
||||
if (!isRecord(patch)) return cloneJson(patch);
|
||||
const next = { ...base };
|
||||
Object.entries(patch).forEach(([key, value]) => {
|
||||
if (value === null) {
|
||||
delete next[key];
|
||||
return;
|
||||
}
|
||||
next[key] = isRecord(next[key]) && isRecord(value) ? mergeJsonObject(next[key], value) : cloneJson(value);
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
const setNestedPath = (root: Record<string, unknown>, path: string, value: unknown): boolean => {
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
if (segments.length === 0) return false;
|
||||
let current: Record<string, unknown> = root;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
if (!isRecord(current[segment])) current[segment] = {};
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
}
|
||||
current[segments[segments.length - 1]] = cloneJson(value);
|
||||
return true;
|
||||
};
|
||||
|
||||
const findMindmapNodeByUid = (node: unknown, uid: string): Record<string, unknown> | null => {
|
||||
if (!isRecord(node)) return null;
|
||||
const data = isRecord(node.data) ? node.data : null;
|
||||
if (typeof data?.uid === "string" && data.uid === uid) return node;
|
||||
const children = Array.isArray(node.children) ? node.children : [];
|
||||
for (const child of children) {
|
||||
const found = findMindmapNodeByUid(child, uid);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const findMindmapNodeContainerByUid = (
|
||||
nodes: unknown[],
|
||||
uid: string,
|
||||
): { parentChildren: Record<string, unknown>[]; index: number } | null => {
|
||||
for (let index = 0; index < nodes.length; index += 1) {
|
||||
const candidate = nodes[index];
|
||||
if (!isRecord(candidate)) continue;
|
||||
const data = isRecord(candidate.data) ? candidate.data : null;
|
||||
if (typeof data?.uid === "string" && data.uid === uid) {
|
||||
return {
|
||||
parentChildren: nodes as Record<string, unknown>[],
|
||||
index,
|
||||
};
|
||||
}
|
||||
const children = Array.isArray(candidate.children) ? candidate.children : [];
|
||||
const found = findMindmapNodeContainerByUid(children, uid);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const createMindmapNodeRecord = (node: {
|
||||
uid?: string;
|
||||
text: string;
|
||||
hyperlink?: string;
|
||||
note?: string;
|
||||
refs?: unknown[];
|
||||
}): Record<string, unknown> => ({
|
||||
data: {
|
||||
uid: node.uid ?? `node_${Date.now().toString(36)}`,
|
||||
text: node.text,
|
||||
...(typeof node.hyperlink === "string" ? { hyperlink: node.hyperlink } : {}),
|
||||
...(typeof node.note === "string" ? { note: node.note } : {}),
|
||||
...(Array.isArray(node.refs) ? { refs: cloneJson(node.refs) } : {}),
|
||||
},
|
||||
children: [],
|
||||
});
|
||||
|
||||
const applyCompatPayloadPatch = (root: Record<string, unknown>, command: MindmapCompatPayloadPatch): boolean => {
|
||||
const path = String(command.path || "").trim();
|
||||
if (!path) return false;
|
||||
|
||||
if (path.startsWith("root.data.")) {
|
||||
const field = path.slice("root.data.".length);
|
||||
const rootData = isRecord(root.data) ? root.data : (root.data = {});
|
||||
return setNestedPath(rootData as Record<string, unknown>, field, command.value);
|
||||
}
|
||||
|
||||
if (path.startsWith("nodes.")) {
|
||||
const [, uid, scope, ...rest] = path.split(".");
|
||||
if (!uid || scope !== "data" || rest.length === 0) return false;
|
||||
const node = findMindmapNodeByUid(root, uid);
|
||||
if (!node) return false;
|
||||
const nodeData = isRecord(node.data) ? node.data : (node.data = {});
|
||||
return setNestedPath(nodeData as Record<string, unknown>, rest.join("."), command.value);
|
||||
}
|
||||
|
||||
const compatPayload = isRecord(root.compatPayload) ? root.compatPayload : (root.compatPayload = {});
|
||||
return setNestedPath(compatPayload as Record<string, unknown>, path, command.value);
|
||||
};
|
||||
|
||||
export const isMetadataOnlyMindmapCommandSet = (commands: unknown[]): commands is MetadataOnlyMindmapCommand[] =>
|
||||
Array.isArray(commands) &&
|
||||
commands.every((command) => {
|
||||
if (!isRecord(command) || typeof command.type !== "string") return false;
|
||||
return ["setLayout", "setTheme", "patchView", "compatPayloadPatch"].includes(command.type);
|
||||
});
|
||||
|
||||
export const isLocallyApplicableMindmapCommandSet = (
|
||||
commands: unknown[],
|
||||
): commands is LocallyApplicableMindmapCommand[] =>
|
||||
Array.isArray(commands) &&
|
||||
commands.every((command) => {
|
||||
if (!isRecord(command) || typeof command.type !== "string") return false;
|
||||
return [
|
||||
"updateText",
|
||||
"insertChild",
|
||||
"insertSiblingAfter",
|
||||
"deleteNode",
|
||||
"setLayout",
|
||||
"setTheme",
|
||||
"patchView",
|
||||
"compatPayloadPatch",
|
||||
].includes(command.type);
|
||||
});
|
||||
|
||||
export const applyMetadataOnlyMindmapCommands = (
|
||||
currentData: unknown,
|
||||
commands: MetadataOnlyMindmapCommand[],
|
||||
): ApplyMetadataOnlyMindmapCommandsResult => {
|
||||
const nextData = ensureMindmapBlob(currentData);
|
||||
const errors: string[] = [];
|
||||
let applied = 0;
|
||||
|
||||
commands.forEach((command) => {
|
||||
if (command.type === "setLayout") {
|
||||
nextData.layout = cloneJson(command.layout);
|
||||
applied += 1;
|
||||
return;
|
||||
}
|
||||
if (command.type === "setTheme") {
|
||||
nextData.theme = cloneJson(command.theme);
|
||||
if (command.themeConfig !== undefined && command.themeConfig !== null) {
|
||||
nextData.themeConfig = cloneJson(command.themeConfig);
|
||||
}
|
||||
applied += 1;
|
||||
return;
|
||||
}
|
||||
if (command.type === "patchView") {
|
||||
nextData.view = mergeJsonObject(nextData.view ?? {}, command.patch) as Record<string, unknown>;
|
||||
applied += 1;
|
||||
return;
|
||||
}
|
||||
if (command.type === "compatPayloadPatch") {
|
||||
if (applyCompatPayloadPatch(nextData, command)) {
|
||||
applied += 1;
|
||||
} else {
|
||||
errors.push(`无法应用 compatPayloadPatch:${command.path}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
applied,
|
||||
data: nextData,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
export const applyMindmapCommandsLocally = (
|
||||
currentData: unknown,
|
||||
commands: LocallyApplicableMindmapCommand[],
|
||||
): ApplyMindmapCommandsLocallyResult => {
|
||||
const nextData = ensureMindmapBlob(currentData);
|
||||
const errors: string[] = [];
|
||||
let applied = 0;
|
||||
|
||||
commands.forEach((command) => {
|
||||
if (command.type === "setLayout" || command.type === "setTheme" || command.type === "patchView" || command.type === "compatPayloadPatch") {
|
||||
const result = applyMetadataOnlyMindmapCommands(nextData, [command]);
|
||||
Object.assign(nextData, result.data);
|
||||
applied += result.applied;
|
||||
errors.push(...result.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.type === "updateText") {
|
||||
const node = findMindmapNodeByUid(nextData, command.nodeId);
|
||||
if (!node) {
|
||||
errors.push(`未找到节点:${command.nodeId}`);
|
||||
return;
|
||||
}
|
||||
const data = isRecord(node.data) ? node.data : (node.data = {});
|
||||
data.text = command.text;
|
||||
applied += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.type === "insertChild") {
|
||||
const parent = findMindmapNodeByUid(nextData, command.parentNodeId);
|
||||
if (!parent) {
|
||||
errors.push(`未找到父节点:${command.parentNodeId}`);
|
||||
return;
|
||||
}
|
||||
const children = Array.isArray(parent.children) ? parent.children : (parent.children = []);
|
||||
children.push(createMindmapNodeRecord(command.node));
|
||||
applied += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.type === "insertSiblingAfter") {
|
||||
const container = findMindmapNodeContainerByUid([nextData], command.targetNodeId);
|
||||
if (!container) {
|
||||
errors.push(`未找到同级节点:${command.targetNodeId}`);
|
||||
return;
|
||||
}
|
||||
if (container.parentChildren[container.index] === nextData) {
|
||||
errors.push("根节点不支持插入同级节点");
|
||||
return;
|
||||
}
|
||||
container.parentChildren.splice(container.index + 1, 0, createMindmapNodeRecord(command.node));
|
||||
applied += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.type === "deleteNode") {
|
||||
const container = findMindmapNodeContainerByUid([nextData], command.nodeId);
|
||||
if (!container) {
|
||||
errors.push(`未找到删除节点:${command.nodeId}`);
|
||||
return;
|
||||
}
|
||||
if (container.parentChildren[container.index] === nextData) {
|
||||
errors.push("根节点不支持删除");
|
||||
return;
|
||||
}
|
||||
container.parentChildren.splice(container.index, 1);
|
||||
applied += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
applied,
|
||||
data: nextData,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildInitialMindmapAssetRefreshPayload } from "./mindmap-initial-sync";
|
||||
|
||||
describe("buildInitialMindmapAssetRefreshPayload", () => {
|
||||
it("远端 createOnly 成功时返回文件树刷新 payload", () => {
|
||||
expect(
|
||||
buildInitialMindmapAssetRefreshPayload({
|
||||
ok: true,
|
||||
docId: "doc-1",
|
||||
mindmapId: "mind-1",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "mind-1",
|
||||
document_id: "doc-1",
|
||||
asset_type: "mindmap",
|
||||
file_name: "mindmap-mind-1.json",
|
||||
file_url: "/documents/doc-1/mindmap-mind-1.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("远端 createOnly 失败时不应伪造文件树刷新事件", () => {
|
||||
expect(
|
||||
buildInitialMindmapAssetRefreshPayload({
|
||||
ok: false,
|
||||
docId: "doc-1",
|
||||
mindmapId: "mind-1",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export type InitialMindmapAssetRefreshPayload = {
|
||||
id: string;
|
||||
document_id: string;
|
||||
asset_type: "mindmap";
|
||||
file_name: string;
|
||||
file_url: string;
|
||||
};
|
||||
|
||||
export function buildInitialMindmapAssetRefreshPayload(input: {
|
||||
ok: boolean;
|
||||
docId: string;
|
||||
mindmapId: string;
|
||||
}): InitialMindmapAssetRefreshPayload | null {
|
||||
if (!input.ok) {
|
||||
return null;
|
||||
}
|
||||
const fileName = `mindmap-${input.mindmapId}.json`;
|
||||
return {
|
||||
id: input.mindmapId,
|
||||
document_id: input.docId,
|
||||
asset_type: "mindmap",
|
||||
file_name: fileName,
|
||||
file_url: `/documents/${input.docId}/${fileName}`,
|
||||
};
|
||||
}
|
||||
@@ -95,6 +95,46 @@ export const defaultMindmapData: MindMapData = {
|
||||
children: [],
|
||||
};
|
||||
|
||||
export const DEFAULT_MINDMAP_LAYOUT = "logicalStructure";
|
||||
export const DEFAULT_MINDMAP_THEME = "default";
|
||||
|
||||
export const createDefaultMindmapThemeConfig = (): Record<string, unknown> => ({
|
||||
lineColor: "#7aa2ff",
|
||||
lineStyle: "curve",
|
||||
rootLineKeepSameInCurve: true,
|
||||
rootLineStartPositionKeepSameInCurve: true,
|
||||
generalizationLineColor: "#ef6a5b",
|
||||
backgroundColor: "#f6f8fc",
|
||||
root: {
|
||||
fillColor: "#e25563",
|
||||
color: "#ffffff",
|
||||
fontWeight: "bold",
|
||||
borderColor: "transparent",
|
||||
borderWidth: 0,
|
||||
borderRadius: 8,
|
||||
},
|
||||
second: {
|
||||
fillColor: "#4f7df3",
|
||||
color: "#ffffff",
|
||||
borderColor: "transparent",
|
||||
borderWidth: 0,
|
||||
borderRadius: 8,
|
||||
},
|
||||
node: {
|
||||
fillColor: "transparent",
|
||||
color: "#315aa9",
|
||||
borderColor: "transparent",
|
||||
borderWidth: 0,
|
||||
},
|
||||
generalization: {
|
||||
fillColor: "#ffffff",
|
||||
color: "#ef6a5b",
|
||||
borderColor: "#ef6a5b",
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
@@ -299,9 +339,9 @@ export const buildMindmapSimpleMindMapScene = (input: {
|
||||
mindmapId: input.mindmapId,
|
||||
rootNodeId,
|
||||
root,
|
||||
layout: "logicalStructure",
|
||||
theme: "classic",
|
||||
themeConfig: {},
|
||||
layout: DEFAULT_MINDMAP_LAYOUT,
|
||||
theme: DEFAULT_MINDMAP_THEME,
|
||||
themeConfig: createDefaultMindmapThemeConfig(),
|
||||
view: { x: 0, y: 0, scale: 1 },
|
||||
config: {},
|
||||
compatPayload: {},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveMindmapShortcutAction,
|
||||
shouldInterceptMindmapShortcut,
|
||||
} from "./mindmap-shortcuts";
|
||||
|
||||
describe("mindmap shortcuts", () => {
|
||||
it("把基础快捷键解析为导图动作", () => {
|
||||
expect(resolveMindmapShortcutAction({ key: "Enter" })).toBe("insertSiblingAfter");
|
||||
expect(resolveMindmapShortcutAction({ key: "Tab" })).toBe("insertChild");
|
||||
expect(resolveMindmapShortcutAction({ key: "Delete" })).toBe("deleteNode");
|
||||
expect(resolveMindmapShortcutAction({ key: "F2" })).toBe("editNode");
|
||||
expect(resolveMindmapShortcutAction({ key: "Enter", ctrlKey: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("已进入 mindmap 时即使暂时没有 active node,也要拦截危险按键,避免 ProseMirror 误删整个 block", () => {
|
||||
expect(
|
||||
shouldInterceptMindmapShortcut({
|
||||
debugChromeEnabled: false,
|
||||
bridgeReady: true,
|
||||
readonly: false,
|
||||
isComposing: false,
|
||||
isEditableTarget: false,
|
||||
targetInsideRoot: false,
|
||||
keyboardShortcutArmed: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldInterceptMindmapShortcut({
|
||||
debugChromeEnabled: false,
|
||||
bridgeReady: true,
|
||||
readonly: false,
|
||||
isComposing: false,
|
||||
isEditableTarget: false,
|
||||
targetInsideRoot: false,
|
||||
keyboardShortcutArmed: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MindmapUiActionId } from "./mindmap-ui-schema";
|
||||
|
||||
type MindmapShortcutEventLike = {
|
||||
key: string;
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
};
|
||||
|
||||
export type MindmapShortcutGateInput = {
|
||||
debugChromeEnabled: boolean;
|
||||
bridgeReady: boolean;
|
||||
readonly: boolean;
|
||||
isComposing: boolean;
|
||||
isEditableTarget: boolean;
|
||||
targetInsideRoot: boolean;
|
||||
keyboardShortcutArmed: boolean;
|
||||
};
|
||||
|
||||
export const resolveMindmapShortcutAction = (
|
||||
event: MindmapShortcutEventLike,
|
||||
): MindmapUiActionId | null => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
|
||||
if (event.key === "Enter") return "insertSiblingAfter";
|
||||
if (event.key === "Tab" || event.key === "Insert") return "insertChild";
|
||||
if (event.key === "Delete" || event.key === "Backspace") return "deleteNode";
|
||||
if (event.key === "F2") return "editNode";
|
||||
return null;
|
||||
};
|
||||
|
||||
export const shouldInterceptMindmapShortcut = (
|
||||
input: MindmapShortcutGateInput,
|
||||
): boolean => {
|
||||
if (input.debugChromeEnabled) return false;
|
||||
if (!input.bridgeReady || input.readonly || input.isComposing) return false;
|
||||
if (input.isEditableTarget) return false;
|
||||
if (input.targetInsideRoot) return true;
|
||||
return input.keyboardShortcutArmed;
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MINDMAP_TOOLBAR_MORE_ACTION_ID,
|
||||
MINDMAP_DEBUG_CHROME_QUERY_PARAM,
|
||||
isMindmapDebugChromeEnabled,
|
||||
mindmapDefaultUiSchema,
|
||||
mindmapToolbarFileActionOrder,
|
||||
mindmapToolbarPrimaryActionOrder,
|
||||
} from "./mindmap-ui-schema";
|
||||
import { getMindmapActionMapping } from "./mindmap-action-map";
|
||||
|
||||
@@ -12,6 +15,7 @@ describe("mindmap UI schema", () => {
|
||||
"history",
|
||||
"node",
|
||||
"insert",
|
||||
"file",
|
||||
"view",
|
||||
]);
|
||||
expect(mindmapDefaultUiSchema.sidebarPanels.map((panel) => panel.id)).toEqual([
|
||||
@@ -20,6 +24,8 @@ describe("mindmap UI schema", () => {
|
||||
"theme",
|
||||
"structure",
|
||||
"outline",
|
||||
"shortcutKey",
|
||||
"settings",
|
||||
]);
|
||||
expect(mindmapDefaultUiSchema.navigatorItems.map((item) => item.id)).toEqual([
|
||||
"stats",
|
||||
@@ -28,6 +34,7 @@ describe("mindmap UI schema", () => {
|
||||
"zoomOut",
|
||||
"zoom",
|
||||
"zoomIn",
|
||||
"fullscreen",
|
||||
"readonly",
|
||||
]);
|
||||
});
|
||||
@@ -48,6 +55,41 @@ describe("mindmap UI schema", () => {
|
||||
expect(new Set(actionIds).size).toBe(actionIds.length);
|
||||
});
|
||||
|
||||
it("单行 toolbar 主按钮顺序对齐 lx-doc/KMind", () => {
|
||||
expect([...mindmapToolbarPrimaryActionOrder, MINDMAP_TOOLBAR_MORE_ACTION_ID]).toEqual([
|
||||
"undo",
|
||||
"redo",
|
||||
"editNode",
|
||||
"insertSiblingAfter",
|
||||
"deleteNode",
|
||||
"insertChild",
|
||||
"tag",
|
||||
"hyperlink",
|
||||
"note",
|
||||
"image",
|
||||
"icon",
|
||||
"summary",
|
||||
"associativeLine",
|
||||
"formula",
|
||||
"more",
|
||||
]);
|
||||
});
|
||||
|
||||
it("toolbar 元信息包含图标、短标签、长标签、优先级和溢出归类", () => {
|
||||
const primaryMeta = mindmapToolbarPrimaryActionOrder.map((actionId) => mindmapDefaultUiSchema.toolbarActionMeta[actionId]);
|
||||
expect(primaryMeta.map((meta) => meta.priority)).toEqual([...primaryMeta.map((meta) => meta.priority)].sort((a, b) => a - b));
|
||||
expect(primaryMeta.every((meta) => meta.cluster === "main")).toBe(true);
|
||||
expect(primaryMeta.every((meta) => meta.iconKey && meta.shortLabel && meta.longLabel && meta.overflowGroup)).toBe(true);
|
||||
});
|
||||
|
||||
it("导入导出属于右侧独立 toolbar cluster", () => {
|
||||
expect([...mindmapToolbarFileActionOrder]).toEqual(["import", "export"]);
|
||||
expect(mindmapToolbarFileActionOrder.map((actionId) => mindmapDefaultUiSchema.toolbarActionMeta[actionId].cluster)).toEqual([
|
||||
"file",
|
||||
"file",
|
||||
]);
|
||||
});
|
||||
|
||||
it("默认 schema 中所有 action 都有 action map 映射", () => {
|
||||
const actionIds = [
|
||||
...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
|
||||
@@ -58,6 +100,16 @@ describe("mindmap UI schema", () => {
|
||||
expect(actionIds.filter((actionId) => getMindmapActionMapping(actionId) === null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("toolbar 真实可见 action 都有 action map,more 仅作为 shell 虚拟按钮", () => {
|
||||
const visibleActionIds = [
|
||||
...mindmapToolbarPrimaryActionOrder,
|
||||
...mindmapToolbarFileActionOrder,
|
||||
];
|
||||
|
||||
expect(visibleActionIds.filter((actionId) => getMindmapActionMapping(actionId) === null)).toEqual([]);
|
||||
expect(mindmapDefaultUiSchema.toolbarActionMeta).not.toHaveProperty(MINDMAP_TOOLBAR_MORE_ACTION_ID);
|
||||
});
|
||||
|
||||
it("第一阶段 context menu 覆盖节点和画布动作", () => {
|
||||
expect(mindmapDefaultUiSchema.contextMenuItems.filter((item) => item.requiresNode).map((item) => item.actionId)).toEqual([
|
||||
"insertChild",
|
||||
@@ -73,27 +125,53 @@ describe("mindmap UI schema", () => {
|
||||
"fitView",
|
||||
"search",
|
||||
"readonly",
|
||||
"showMenu",
|
||||
]);
|
||||
});
|
||||
|
||||
it("第一阶段 sidebar panel 数量受控", () => {
|
||||
expect(mindmapDefaultUiSchema.sidebarPanels).toHaveLength(5);
|
||||
expect(mindmapDefaultUiSchema.sidebarPanels).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("第一阶段 sidebar 暴露主题、结构和 compat patch 选项", () => {
|
||||
const themePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "theme");
|
||||
expect(themePanel?.options.map((option) => option.value)).toEqual(["classic", "classic4"]);
|
||||
expect(themePanel?.options.map((option) => option.value)).toEqual(["classic", "classic4", "simple", "dark"]);
|
||||
|
||||
const structurePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "structure");
|
||||
expect(structurePanel?.options.map((option) => option.value)).toEqual([
|
||||
"logicalStructure",
|
||||
"mindMap",
|
||||
"organizationStructure",
|
||||
"catalogOrganization",
|
||||
"timeline",
|
||||
"fishbone",
|
||||
]);
|
||||
expect(structurePanel?.options.every((option) => option.controlType === "layoutCard")).toBe(true);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("每个 sidebar option 要么可写,要么显式标记只读", () => {
|
||||
const options = mindmapDefaultUiSchema.sidebarPanels.flatMap((panel) => panel.options);
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
expect(options.every((option) => option.actionId !== null || option.readonly === true)).toBe(true);
|
||||
});
|
||||
|
||||
it("可写 sidebar option 必须能映射到 compat patch 或 kernel command", () => {
|
||||
const writableOptions = mindmapDefaultUiSchema.sidebarPanels.flatMap((panel) =>
|
||||
panel.options.filter((option) => option.readonly !== true),
|
||||
);
|
||||
expect(writableOptions.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
writableOptions.every((option) => {
|
||||
if (option.compatPath) return true;
|
||||
if (!option.actionId) return false;
|
||||
const mapping = getMindmapActionMapping(option.actionId);
|
||||
return mapping?.target === "kernelCommand" || mapping?.target === "compatPatch";
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,20 +24,45 @@ export type MindmapUiActionId =
|
||||
| "zoomOut"
|
||||
| "fitView"
|
||||
| "centerRoot"
|
||||
| "fullscreenCanvas"
|
||||
| "fullscreenPage"
|
||||
| "exitFullscreen"
|
||||
| "search"
|
||||
| "showMenu"
|
||||
| "expandCollapse"
|
||||
| "copyNodeText"
|
||||
| "readonly";
|
||||
|
||||
export const MINDMAP_TOOLBAR_MORE_ACTION_ID = "more" as const;
|
||||
|
||||
export type MindmapToolbarVirtualActionId = typeof MINDMAP_TOOLBAR_MORE_ACTION_ID;
|
||||
|
||||
export type MindmapToolbarActionId = MindmapUiActionId | MindmapToolbarVirtualActionId;
|
||||
|
||||
export type MindmapToolbarActionCluster = "main" | "file" | "view";
|
||||
|
||||
export type MindmapToolbarOverflowGroup = "history" | "node" | "insert" | "file" | "view";
|
||||
|
||||
export type MindmapToolbarActionMeta = {
|
||||
id: MindmapUiActionId;
|
||||
iconKey: string;
|
||||
shortLabel: string;
|
||||
longLabel: string;
|
||||
priority: number;
|
||||
overflowGroup: MindmapToolbarOverflowGroup;
|
||||
cluster: MindmapToolbarActionCluster;
|
||||
};
|
||||
|
||||
export type MindmapToolbarGroup = {
|
||||
id: "history" | "node" | "insert" | "view";
|
||||
id: MindmapToolbarOverflowGroup;
|
||||
label: string;
|
||||
actions: MindmapUiActionId[];
|
||||
collapsePriority: number;
|
||||
};
|
||||
|
||||
export type MindmapSidebarPanel = {
|
||||
id: "nodeStyle" | "baseStyle" | "theme" | "structure" | "outline";
|
||||
id: MindmapSidebarPanelId;
|
||||
kind: MindmapSidebarPanelId;
|
||||
label: string;
|
||||
icon: string;
|
||||
runtimeCapability: string;
|
||||
@@ -45,16 +70,40 @@ export type MindmapSidebarPanel = {
|
||||
options: MindmapSidebarOption[];
|
||||
};
|
||||
|
||||
export type MindmapSidebarPanelId =
|
||||
| "nodeStyle"
|
||||
| "baseStyle"
|
||||
| "theme"
|
||||
| "structure"
|
||||
| "outline"
|
||||
| "shortcutKey"
|
||||
| "settings";
|
||||
|
||||
export type MindmapSidebarOptionControlType =
|
||||
| "button"
|
||||
| "swatch"
|
||||
| "segmented"
|
||||
| "slider"
|
||||
| "numberInput"
|
||||
| "select"
|
||||
| "layoutCard"
|
||||
| "treeItem"
|
||||
| "toggle";
|
||||
|
||||
export type MindmapSidebarOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
actionId: MindmapUiActionId | null;
|
||||
value: unknown;
|
||||
controlType: MindmapSidebarOptionControlType;
|
||||
preview?: string;
|
||||
description?: string;
|
||||
readonly?: boolean;
|
||||
compatPath?: string;
|
||||
};
|
||||
|
||||
export type MindmapNavigatorItem = {
|
||||
id: "stats" | "centerRoot" | "search" | "zoomOut" | "zoom" | "zoomIn" | "readonly";
|
||||
id: "stats" | "centerRoot" | "search" | "zoomOut" | "zoom" | "zoomIn" | "fullscreen" | "readonly";
|
||||
label: string;
|
||||
actionId: MindmapUiActionId | null;
|
||||
readOnly: boolean;
|
||||
@@ -71,11 +120,49 @@ export type MindmapContextMenuItem = {
|
||||
|
||||
export type MindmapUiSchema = {
|
||||
toolbarGroups: MindmapToolbarGroup[];
|
||||
toolbarActionMeta: Record<MindmapUiActionId, MindmapToolbarActionMeta>;
|
||||
sidebarPanels: MindmapSidebarPanel[];
|
||||
navigatorItems: MindmapNavigatorItem[];
|
||||
contextMenuItems: MindmapContextMenuItem[];
|
||||
};
|
||||
|
||||
export const mindmapToolbarPrimaryActionOrder = [
|
||||
"undo",
|
||||
"redo",
|
||||
"editNode",
|
||||
"insertSiblingAfter",
|
||||
"deleteNode",
|
||||
"insertChild",
|
||||
"tag",
|
||||
"hyperlink",
|
||||
"note",
|
||||
"image",
|
||||
"icon",
|
||||
"summary",
|
||||
"associativeLine",
|
||||
"formula",
|
||||
] as const satisfies readonly MindmapUiActionId[];
|
||||
|
||||
export const mindmapToolbarFileActionOrder = ["import", "export"] as const satisfies readonly MindmapUiActionId[];
|
||||
|
||||
const createToolbarActionMeta = (
|
||||
id: MindmapUiActionId,
|
||||
iconKey: string,
|
||||
shortLabel: string,
|
||||
longLabel: string,
|
||||
priority: number,
|
||||
overflowGroup: MindmapToolbarOverflowGroup,
|
||||
cluster: MindmapToolbarActionCluster,
|
||||
): MindmapToolbarActionMeta => ({
|
||||
id,
|
||||
iconKey,
|
||||
shortLabel,
|
||||
longLabel,
|
||||
priority,
|
||||
overflowGroup,
|
||||
cluster,
|
||||
});
|
||||
|
||||
export const mindmapDefaultUiSchema: MindmapUiSchema = {
|
||||
toolbarGroups: [
|
||||
{
|
||||
@@ -87,84 +174,177 @@ export const mindmapDefaultUiSchema: MindmapUiSchema = {
|
||||
{
|
||||
id: "node",
|
||||
label: "节点",
|
||||
actions: ["editNode", "insertSiblingAfter", "insertChild", "deleteNode"],
|
||||
actions: ["editNode", "insertSiblingAfter", "deleteNode", "insertChild"],
|
||||
collapsePriority: 1,
|
||||
},
|
||||
{
|
||||
id: "insert",
|
||||
label: "插入",
|
||||
actions: ["tag", "hyperlink", "note", "image", "icon", "summary", "associativeLine", "formula", "painter", "import", "export"],
|
||||
actions: ["tag", "hyperlink", "note", "image", "icon", "summary", "associativeLine", "formula"],
|
||||
collapsePriority: 2,
|
||||
},
|
||||
{
|
||||
id: "file",
|
||||
label: "文件",
|
||||
actions: ["import", "export"],
|
||||
collapsePriority: 5,
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
label: "视图",
|
||||
actions: ["centerRoot", "zoomOut", "zoomIn", "search", "readonly"],
|
||||
actions: ["painter", "centerRoot", "zoomOut", "zoomIn", "search", "readonly"],
|
||||
collapsePriority: 3,
|
||||
},
|
||||
],
|
||||
toolbarActionMeta: {
|
||||
undo: createToolbarActionMeta("undo", "undo", "撤销", "撤销", 10, "history", "main"),
|
||||
redo: createToolbarActionMeta("redo", "redo", "重做", "重做", 20, "history", "main"),
|
||||
editNode: createToolbarActionMeta("editNode", "type", "编辑", "编辑节点", 30, "node", "main"),
|
||||
insertSiblingAfter: createToolbarActionMeta("insertSiblingAfter", "sibling", "同级", "插入同级节点", 40, "node", "main"),
|
||||
deleteNode: createToolbarActionMeta("deleteNode", "trash", "删除", "删除节点", 50, "node", "main"),
|
||||
insertChild: createToolbarActionMeta("insertChild", "child", "子级", "插入子节点", 60, "node", "main"),
|
||||
tag: createToolbarActionMeta("tag", "tag", "标签", "标签", 70, "insert", "main"),
|
||||
hyperlink: createToolbarActionMeta("hyperlink", "link", "链接", "超链接", 80, "insert", "main"),
|
||||
note: createToolbarActionMeta("note", "note", "备注", "备注", 90, "insert", "main"),
|
||||
image: createToolbarActionMeta("image", "image", "图片", "图片", 100, "insert", "main"),
|
||||
icon: createToolbarActionMeta("icon", "smile", "图标", "图标", 110, "insert", "main"),
|
||||
summary: createToolbarActionMeta("summary", "summary", "概要", "概要", 120, "insert", "main"),
|
||||
associativeLine: createToolbarActionMeta("associativeLine", "route", "关联", "关联线", 130, "insert", "main"),
|
||||
formula: createToolbarActionMeta("formula", "formula", "公式", "公式", 140, "insert", "main"),
|
||||
painter: createToolbarActionMeta("painter", "paintbrush", "格式", "格式刷", 210, "view", "view"),
|
||||
import: createToolbarActionMeta("import", "import", "导入", "导入", 310, "file", "file"),
|
||||
export: createToolbarActionMeta("export", "export", "导出", "导出", 320, "file", "file"),
|
||||
setTheme: createToolbarActionMeta("setTheme", "palette", "主题", "主题", 410, "view", "view"),
|
||||
setLayout: createToolbarActionMeta("setLayout", "layout", "结构", "结构", 420, "view", "view"),
|
||||
zoomIn: createToolbarActionMeta("zoomIn", "zoom-in", "放大", "放大", 510, "view", "view"),
|
||||
zoomOut: createToolbarActionMeta("zoomOut", "zoom-out", "缩小", "缩小", 500, "view", "view"),
|
||||
fitView: createToolbarActionMeta("fitView", "fit", "适应", "适应画布", 520, "view", "view"),
|
||||
centerRoot: createToolbarActionMeta("centerRoot", "target", "回根", "回到根节点", 490, "view", "view"),
|
||||
fullscreenCanvas: createToolbarActionMeta("fullscreenCanvas", "fullscreen", "全屏", "全屏查看", 550, "view", "view"),
|
||||
fullscreenPage: createToolbarActionMeta("fullscreenPage", "fullscreen-page", "全页", "全屏编辑", 560, "view", "view"),
|
||||
exitFullscreen: createToolbarActionMeta("exitFullscreen", "exit-fullscreen", "退出", "退出全屏", 570, "view", "view"),
|
||||
search: createToolbarActionMeta("search", "search", "搜索", "搜索", 530, "view", "view"),
|
||||
showMenu: createToolbarActionMeta("showMenu", "menu", "菜单", "显示菜单", 580, "view", "view"),
|
||||
expandCollapse: createToolbarActionMeta("expandCollapse", "expand", "展开", "展开/收起", 610, "view", "view"),
|
||||
copyNodeText: createToolbarActionMeta("copyNodeText", "copy", "复制", "复制文本", 620, "view", "view"),
|
||||
readonly: createToolbarActionMeta("readonly", "lock", "只读", "只读", 540, "view", "view"),
|
||||
},
|
||||
sidebarPanels: [
|
||||
{
|
||||
id: "nodeStyle",
|
||||
kind: "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: "node-fill-blue", label: "海蓝", actionId: "painter", value: "#dbeafe", controlType: "swatch", preview: "#dbeafe", compatPath: "nodes.$active.data.fillColor" },
|
||||
{ id: "node-fill-green", label: "薄荷", actionId: "painter", value: "#dcfce7", controlType: "swatch", preview: "#dcfce7", compatPath: "nodes.$active.data.fillColor" },
|
||||
{ id: "node-fill-amber", label: "暖黄", actionId: "painter", value: "#fef3c7", controlType: "swatch", preview: "#fef3c7", compatPath: "nodes.$active.data.fillColor" },
|
||||
{ id: "node-text-dark", label: "深色文字", actionId: "painter", value: "#0f172a", controlType: "swatch", preview: "#0f172a", compatPath: "nodes.$active.data.color" },
|
||||
{ id: "node-text-blue", label: "蓝色文字", actionId: "painter", value: "#1d4ed8", controlType: "swatch", preview: "#1d4ed8", compatPath: "nodes.$active.data.color" },
|
||||
{ id: "node-font-14", label: "14 px", actionId: "painter", value: 14, controlType: "segmented", compatPath: "nodes.$active.data.fontSize" },
|
||||
{ id: "node-font-18", label: "18 px", actionId: "painter", value: 18, controlType: "segmented", compatPath: "nodes.$active.data.fontSize" },
|
||||
{ id: "node-font-bold", label: "加粗", actionId: "painter", value: true, controlType: "toggle", compatPath: "nodes.$active.data.fontWeight" },
|
||||
{ id: "node-font-italic", label: "斜体", actionId: "painter", value: true, controlType: "toggle", compatPath: "nodes.$active.data.fontStyle" },
|
||||
{ id: "node-shape-round", label: "圆角矩形", actionId: "painter", value: "roundedRectangle", controlType: "button", compatPath: "nodes.$active.data.shape" },
|
||||
{ id: "node-shape-rect", label: "矩形", actionId: "painter", value: "rectangle", controlType: "button", compatPath: "nodes.$active.data.shape" },
|
||||
{ id: "node-border-blue", label: "蓝色边框", actionId: "painter", value: "#60a5fa", controlType: "swatch", preview: "#60a5fa", compatPath: "nodes.$active.data.borderColor" },
|
||||
{ id: "node-line-teal", label: "青色分支线", actionId: "painter", value: "#14b8a6", controlType: "swatch", preview: "#14b8a6", compatPath: "nodes.$active.data.lineColor" },
|
||||
{ id: "node-line-width-2", label: "边线 2", actionId: "painter", value: 2, controlType: "segmented", compatPath: "nodes.$active.data.lineWidth" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "baseStyle",
|
||||
kind: "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: "base-curve-line", label: "曲线", actionId: "painter", value: "curve", controlType: "segmented", compatPath: "style.map.lineStyle" },
|
||||
{ id: "base-direct-line", label: "直线", actionId: "painter", value: "straight", controlType: "segmented", compatPath: "style.map.lineStyle" },
|
||||
{ id: "base-rainbow-lines", label: "彩虹线条", actionId: "painter", value: { enabled: true }, controlType: "toggle", compatPath: "style.map.rainbowLines" },
|
||||
{ id: "base-line-width-2", label: "线宽 2", actionId: "painter", value: 2, controlType: "segmented", compatPath: "style.map.lineWidth" },
|
||||
{ id: "base-line-width-4", label: "线宽 4", actionId: "painter", value: 4, controlType: "segmented", compatPath: "style.map.lineWidth" },
|
||||
{ id: "base-background-light", label: "浅色背景", actionId: "painter", value: "#f8fafc", controlType: "swatch", preview: "#f8fafc", compatPath: "style.map.backgroundColor" },
|
||||
{ id: "base-node-spacing-36", label: "节点间距 36", actionId: "painter", value: 36, controlType: "numberInput", compatPath: "style.map.nodeSpacing" },
|
||||
{ id: "base-summary-bracket", label: "括号概要", actionId: "painter", value: "bracket", controlType: "select", compatPath: "style.map.summaryStyle" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "theme",
|
||||
kind: "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: "theme-classic", label: "Classic", actionId: "setTheme", value: "classic", controlType: "swatch", preview: "#60a5fa", description: "默认主题" },
|
||||
{ id: "theme-classic4", label: "KMind", actionId: "setTheme", value: "classic4", controlType: "swatch", preview: "#22c55e", description: "KMind-like" },
|
||||
{ id: "theme-simple", label: "Simple", actionId: "setTheme", value: "simple", controlType: "swatch", preview: "#f59e0b", description: "轻量主题" },
|
||||
{ id: "theme-dark", label: "Dark", actionId: "setTheme", value: "dark", controlType: "swatch", preview: "#334155", description: "深色主题" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "structure",
|
||||
kind: "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: "layout-logical", label: "逻辑结构图", actionId: "setLayout", value: "logicalStructure", controlType: "layoutCard", preview: "logicalStructure" },
|
||||
{ id: "layout-mind-map", label: "思维导图", actionId: "setLayout", value: "mindMap", controlType: "layoutCard", preview: "mindMap" },
|
||||
{ id: "layout-organization", label: "组织结构图", actionId: "setLayout", value: "organizationStructure", controlType: "layoutCard", preview: "organizationStructure" },
|
||||
{ id: "layout-catalog", label: "目录组织图", actionId: "setLayout", value: "catalogOrganization", controlType: "layoutCard", preview: "catalogOrganization" },
|
||||
{ id: "layout-timeline", label: "时间轴", actionId: "setLayout", value: "timeline", controlType: "layoutCard", preview: "timeline" },
|
||||
{ id: "layout-fishbone", label: "鱼骨图", actionId: "setLayout", value: "fishbone", controlType: "layoutCard", preview: "fishbone" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "outline",
|
||||
kind: "outline",
|
||||
label: "大纲",
|
||||
icon: "list-tree",
|
||||
runtimeCapability: "outline",
|
||||
phase: 1,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: "shortcutKey",
|
||||
kind: "shortcutKey",
|
||||
label: "快捷键",
|
||||
icon: "sparkles",
|
||||
runtimeCapability: "shortcut-key",
|
||||
phase: 1,
|
||||
options: [
|
||||
{ id: "shortcut-insert-child", label: "Tab", description: "插入子节点", actionId: null, value: null, controlType: "treeItem", readonly: true },
|
||||
{ id: "shortcut-insert-sibling", label: "Enter", description: "插入同级节点", actionId: null, value: null, controlType: "treeItem", readonly: true },
|
||||
{ id: "shortcut-delete", label: "Delete", description: "删除节点", actionId: null, value: null, controlType: "treeItem", readonly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
kind: "settings",
|
||||
label: "设置",
|
||||
icon: "hexagon",
|
||||
runtimeCapability: "settings",
|
||||
phase: 1,
|
||||
options: [
|
||||
{ id: "settings-readonly-hint", label: "只读模式", description: "导航栏切换", actionId: null, value: null, controlType: "toggle", readonly: true },
|
||||
{ id: "settings-mouse", label: "鼠标行为", description: "左键选中,右键拖拽", actionId: null, value: "leftSelectRightDrag", controlType: "select", readonly: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
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: "search", label: "搜索", actionId: "search", readOnly: true, displayMode: "button" },
|
||||
{ id: "zoomOut", label: "缩小", actionId: "zoomOut", readOnly: true, displayMode: "button" },
|
||||
{ id: "zoom", label: "缩放", actionId: null, readOnly: true, displayMode: "text" },
|
||||
{ id: "zoom", label: "缩放", actionId: null, readOnly: true, displayMode: "input" },
|
||||
{ id: "zoomIn", label: "放大", actionId: "zoomIn", readOnly: true, displayMode: "button" },
|
||||
{ id: "fullscreen", label: "全屏", actionId: "fullscreenCanvas", readOnly: true, displayMode: "button" },
|
||||
{ id: "readonly", label: "只读", actionId: "readonly", readOnly: true, displayMode: "button" },
|
||||
],
|
||||
contextMenuItems: [
|
||||
@@ -179,6 +359,7 @@ export const mindmapDefaultUiSchema: MindmapUiSchema = {
|
||||
{ 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 },
|
||||
{ id: "showMenu", label: "显示菜单", actionId: "showMenu", requiresNode: false, phase: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveMindmapUiState } from "./mindmap-ui-state";
|
||||
import {
|
||||
createDefaultMindmapShellInteractionState,
|
||||
deriveMindmapUiState,
|
||||
reduceMindmapChromeVisibility,
|
||||
} from "./mindmap-ui-state";
|
||||
|
||||
describe("mindmap UI state", () => {
|
||||
it("无 active node 时禁用节点编辑动作,但保留视图动作", () => {
|
||||
@@ -11,6 +15,8 @@ describe("mindmap UI state", () => {
|
||||
expect(state.disabledActions.expandCollapse).toBe(true);
|
||||
expect(state.disabledActions.zoomIn).toBe(false);
|
||||
expect(state.disabledActions.centerRoot).toBe(false);
|
||||
expect(state.disabledActions.fullscreenCanvas).toBe(false);
|
||||
expect(state.disabledActions.exitFullscreen).toBe(false);
|
||||
});
|
||||
|
||||
it("readonly 时禁用编辑动作,保留搜索和视图动作", () => {
|
||||
@@ -23,6 +29,8 @@ describe("mindmap UI state", () => {
|
||||
expect(state.disabledActions.copyNodeText).toBe(false);
|
||||
expect(state.disabledActions.search).toBe(false);
|
||||
expect(state.disabledActions.zoomOut).toBe(false);
|
||||
expect(state.disabledActions.fullscreenCanvas).toBe(false);
|
||||
expect(state.disabledActions.fullscreenPage).toBe(false);
|
||||
});
|
||||
|
||||
it("缺少 runtime capability 时禁用对应 action", () => {
|
||||
@@ -35,4 +43,97 @@ describe("mindmap UI state", () => {
|
||||
expect(state.disabledActions.note).toBe(true);
|
||||
expect(state.disabledActions.insertChild).toBe(false);
|
||||
});
|
||||
|
||||
it("派生默认 shell state,给 Leptos shell 提供稳定合同", () => {
|
||||
const state = deriveMindmapUiState({
|
||||
activeNodeId: "node_1",
|
||||
readonly: true,
|
||||
shell: {
|
||||
chromeVisibility: "hiddenByPointerLeave",
|
||||
toolbarOverflow: {
|
||||
availableWidth: 640,
|
||||
visibleActionIds: ["undo", "redo"],
|
||||
overflowActionIds: ["image"],
|
||||
moreOpen: true,
|
||||
},
|
||||
fullscreen: {
|
||||
mode: "canvas",
|
||||
isFullscreen: true,
|
||||
apiAvailable: false,
|
||||
},
|
||||
sidebar: {
|
||||
triggerVisible: false,
|
||||
panelOpen: false,
|
||||
activePanelId: "structure",
|
||||
collapsedByToggle: true,
|
||||
},
|
||||
navigator: {
|
||||
searchOpen: true,
|
||||
minimapOpen: true,
|
||||
zoomPercent: 138.4,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.shell.chromeVisibility).toBe("hiddenByPointerLeave");
|
||||
expect(state.shell.toolbarOverflow).toMatchObject({
|
||||
availableWidth: 640,
|
||||
visibleActionIds: ["undo", "redo"],
|
||||
overflowActionIds: ["image"],
|
||||
moreOpen: true,
|
||||
});
|
||||
expect(state.shell.fullscreen).toMatchObject({
|
||||
mode: "canvas",
|
||||
isFullscreen: true,
|
||||
target: "mindmap-root",
|
||||
apiAvailable: false,
|
||||
});
|
||||
expect(state.shell.sidebar).toMatchObject({
|
||||
triggerVisible: false,
|
||||
panelOpen: false,
|
||||
activePanelId: "structure",
|
||||
drawerWidth: 300,
|
||||
collapsedByToggle: true,
|
||||
});
|
||||
expect(state.shell.navigator).toMatchObject({
|
||||
searchOpen: true,
|
||||
minimapOpen: true,
|
||||
readonly: true,
|
||||
zoomPercent: 138,
|
||||
mouseBehavior: "leftSelectRightDrag",
|
||||
});
|
||||
});
|
||||
|
||||
it("鼠标移出隐藏 chrome,鼠标移入不自动恢复,点击才恢复", () => {
|
||||
const hidden = reduceMindmapChromeVisibility("visible", "pointerLeave");
|
||||
expect(hidden).toBe("hiddenByPointerLeave");
|
||||
expect(reduceMindmapChromeVisibility(hidden, "pointerEnter")).toBe("hiddenByPointerLeave");
|
||||
expect(reduceMindmapChromeVisibility(hidden, "restoreClick")).toBe("visible");
|
||||
});
|
||||
|
||||
it("sidebar 隐藏触发条后保留最近 active panel", () => {
|
||||
const state = createDefaultMindmapShellInteractionState({
|
||||
sidebar: {
|
||||
triggerVisible: false,
|
||||
panelOpen: false,
|
||||
activePanelId: "structure",
|
||||
collapsedByToggle: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.sidebar.triggerVisible).toBe(false);
|
||||
expect(state.sidebar.panelOpen).toBe(false);
|
||||
expect(state.sidebar.activePanelId).toBe("structure");
|
||||
expect(state.sidebar.collapsedByToggle).toBe(true);
|
||||
|
||||
const restored = createDefaultMindmapShellInteractionState({
|
||||
sidebar: {
|
||||
...state.sidebar,
|
||||
triggerVisible: true,
|
||||
panelOpen: true,
|
||||
collapsedByToggle: false,
|
||||
},
|
||||
});
|
||||
expect(restored.sidebar.activePanelId).toBe("structure");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,18 +3,81 @@ import { mindmapDefaultUiSchema, type MindmapUiActionId } from "./mindmap-ui-sch
|
||||
|
||||
export type MindmapRuntimeCapability = MindmapActionTarget;
|
||||
|
||||
export type MindmapChromeVisibilityState =
|
||||
| "visible"
|
||||
| "hiddenByPointerLeave"
|
||||
| "hiddenByToggle"
|
||||
| "hiddenByFullscreen";
|
||||
|
||||
export type MindmapToolbarOverflowState = {
|
||||
availableWidth: number | null;
|
||||
visibleActionIds: MindmapUiActionId[];
|
||||
overflowActionIds: MindmapUiActionId[];
|
||||
moreOpen: boolean;
|
||||
};
|
||||
|
||||
export type MindmapFullscreenState = {
|
||||
mode: "none" | "canvas" | "page";
|
||||
isFullscreen: boolean;
|
||||
target: "mindmap-root" | "document-body";
|
||||
apiAvailable: boolean;
|
||||
};
|
||||
|
||||
export type MindmapSidebarState = {
|
||||
triggerVisible: boolean;
|
||||
panelOpen: boolean;
|
||||
activePanelId: string | null;
|
||||
drawerWidth: number;
|
||||
collapsedByToggle: boolean;
|
||||
};
|
||||
|
||||
export type MindmapNavigatorState = {
|
||||
searchOpen: boolean;
|
||||
minimapOpen: boolean;
|
||||
readonly: boolean;
|
||||
zoomPercent: number;
|
||||
mouseBehavior: "leftSelectRightDrag" | "leftDragRightMenu";
|
||||
};
|
||||
|
||||
export type MindmapShellInteractionState = {
|
||||
chromeVisibility: MindmapChromeVisibilityState;
|
||||
toolbarOverflow: MindmapToolbarOverflowState;
|
||||
fullscreen: MindmapFullscreenState;
|
||||
sidebar: MindmapSidebarState;
|
||||
navigator: MindmapNavigatorState;
|
||||
};
|
||||
|
||||
export type MindmapUiStateInput = {
|
||||
activeNodeId?: string | null;
|
||||
readonly: boolean;
|
||||
runtimeCapabilities?: MindmapRuntimeCapability[];
|
||||
shell?: PartialMindmapShellInteractionState;
|
||||
};
|
||||
|
||||
export type MindmapUiState = {
|
||||
activeNodeId: string | null;
|
||||
readonly: boolean;
|
||||
disabledActions: Record<MindmapUiActionId, boolean>;
|
||||
shell: MindmapShellInteractionState;
|
||||
};
|
||||
|
||||
export type PartialMindmapShellInteractionState = {
|
||||
chromeVisibility?: MindmapChromeVisibilityState;
|
||||
toolbarOverflow?: Partial<MindmapToolbarOverflowState>;
|
||||
fullscreen?: Partial<MindmapFullscreenState>;
|
||||
sidebar?: Partial<MindmapSidebarState>;
|
||||
navigator?: Partial<MindmapNavigatorState>;
|
||||
};
|
||||
|
||||
export type MindmapChromeVisibilityEvent =
|
||||
| "pointerLeave"
|
||||
| "pointerEnter"
|
||||
| "restoreClick"
|
||||
| "hideToggle"
|
||||
| "showToggle"
|
||||
| "enterFullscreen"
|
||||
| "exitFullscreen";
|
||||
|
||||
const allActionIds = (): MindmapUiActionId[] => {
|
||||
const ids = [
|
||||
...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
|
||||
@@ -30,10 +93,80 @@ const normalizeActiveNodeId = (value: string | null | undefined): string | null
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
const normalizeZoomPercent = (value: number | undefined): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return 100;
|
||||
return Math.max(10, Math.min(500, Math.round(value)));
|
||||
};
|
||||
|
||||
const unsupportedActionIds = new Set<MindmapUiActionId>([
|
||||
"tag",
|
||||
"hyperlink",
|
||||
"note",
|
||||
"image",
|
||||
"icon",
|
||||
"associativeLine",
|
||||
"formula",
|
||||
"import",
|
||||
"export",
|
||||
]);
|
||||
|
||||
export const createDefaultMindmapShellInteractionState = (
|
||||
input: PartialMindmapShellInteractionState = {},
|
||||
): MindmapShellInteractionState => ({
|
||||
chromeVisibility: input.chromeVisibility ?? "visible",
|
||||
toolbarOverflow: {
|
||||
availableWidth: input.toolbarOverflow?.availableWidth ?? null,
|
||||
visibleActionIds: input.toolbarOverflow?.visibleActionIds ?? [],
|
||||
overflowActionIds: input.toolbarOverflow?.overflowActionIds ?? [],
|
||||
moreOpen: input.toolbarOverflow?.moreOpen ?? false,
|
||||
},
|
||||
fullscreen: {
|
||||
mode: input.fullscreen?.mode ?? "none",
|
||||
isFullscreen: input.fullscreen?.isFullscreen ?? false,
|
||||
target: input.fullscreen?.target ?? "mindmap-root",
|
||||
apiAvailable: input.fullscreen?.apiAvailable ?? true,
|
||||
},
|
||||
sidebar: {
|
||||
triggerVisible: input.sidebar?.triggerVisible ?? true,
|
||||
panelOpen: input.sidebar?.panelOpen ?? true,
|
||||
activePanelId: input.sidebar?.activePanelId ?? mindmapDefaultUiSchema.sidebarPanels[0]?.id ?? null,
|
||||
drawerWidth: input.sidebar?.drawerWidth ?? 300,
|
||||
collapsedByToggle: input.sidebar?.collapsedByToggle ?? false,
|
||||
},
|
||||
navigator: {
|
||||
searchOpen: input.navigator?.searchOpen ?? false,
|
||||
minimapOpen: input.navigator?.minimapOpen ?? false,
|
||||
readonly: input.navigator?.readonly ?? false,
|
||||
zoomPercent: normalizeZoomPercent(input.navigator?.zoomPercent),
|
||||
mouseBehavior: input.navigator?.mouseBehavior ?? "leftSelectRightDrag",
|
||||
},
|
||||
});
|
||||
|
||||
export const reduceMindmapChromeVisibility = (
|
||||
state: MindmapChromeVisibilityState,
|
||||
event: MindmapChromeVisibilityEvent,
|
||||
): MindmapChromeVisibilityState => {
|
||||
if (event === "pointerLeave") return "hiddenByPointerLeave";
|
||||
if (event === "pointerEnter") return state;
|
||||
if (event === "restoreClick") return "visible";
|
||||
if (event === "hideToggle") return "hiddenByToggle";
|
||||
if (event === "showToggle") return "visible";
|
||||
if (event === "enterFullscreen") return state === "visible" ? "visible" : "hiddenByFullscreen";
|
||||
if (event === "exitFullscreen") return state === "hiddenByFullscreen" ? "visible" : state;
|
||||
return state;
|
||||
};
|
||||
|
||||
export const deriveMindmapUiState = (input: MindmapUiStateInput): MindmapUiState => {
|
||||
const activeNodeId = normalizeActiveNodeId(input.activeNodeId);
|
||||
const capabilities = input.runtimeCapabilities ? new Set<MindmapRuntimeCapability>(input.runtimeCapabilities) : null;
|
||||
const disabledActions = {} as Record<MindmapUiActionId, boolean>;
|
||||
const shell = createDefaultMindmapShellInteractionState({
|
||||
...input.shell,
|
||||
navigator: {
|
||||
...input.shell?.navigator,
|
||||
readonly: input.readonly,
|
||||
},
|
||||
});
|
||||
|
||||
allActionIds().forEach((actionId) => {
|
||||
const mapping = getMindmapActionMapping(actionId);
|
||||
@@ -53,6 +186,10 @@ export const deriveMindmapUiState = (input: MindmapUiStateInput): MindmapUiState
|
||||
disabledActions[actionId] = true;
|
||||
return;
|
||||
}
|
||||
if (unsupportedActionIds.has(actionId)) {
|
||||
disabledActions[actionId] = true;
|
||||
return;
|
||||
}
|
||||
disabledActions[actionId] = false;
|
||||
});
|
||||
|
||||
@@ -60,5 +197,6 @@ export const deriveMindmapUiState = (input: MindmapUiStateInput): MindmapUiState
|
||||
activeNodeId,
|
||||
readonly: input.readonly,
|
||||
disabledActions,
|
||||
shell,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,6 +56,32 @@ describe("simple-mind-map bridge contract", () => {
|
||||
expect(options.viewData).toEqual({ state: { scale: 0.8, x: 1, y: 2 }, transform: { scaleX: 0.8, scaleY: 0.8 } });
|
||||
});
|
||||
|
||||
it("fallback projection 默认使用 KMind 风格主题与右向逻辑结构", () => {
|
||||
const projection = createFallbackMindmapAdapterProjection({
|
||||
mindmapId: "mind_1",
|
||||
root: { data: { text: "KMIND" }, children: [] },
|
||||
});
|
||||
|
||||
expect(projection.layout).toBe("logicalStructure");
|
||||
expect(projection.theme).toBe("default");
|
||||
expect(projection.themeConfig).toMatchObject({
|
||||
lineStyle: "curve",
|
||||
root: {
|
||||
fillColor: "#e25563",
|
||||
color: "#ffffff",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
second: {
|
||||
fillColor: "#4f7df3",
|
||||
color: "#ffffff",
|
||||
},
|
||||
node: {
|
||||
color: "#315aa9",
|
||||
},
|
||||
generalizationLineColor: "#ef6a5b",
|
||||
});
|
||||
});
|
||||
|
||||
it("保留 runtimeOptions 中的 fit 设置,避免初始导图被裁切", () => {
|
||||
const projection = createFallbackMindmapAdapterProjection({
|
||||
mindmapId: "mind_1",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
canonicalizeMindmapData,
|
||||
createDefaultMindmapThemeConfig,
|
||||
DEFAULT_MINDMAP_LAYOUT,
|
||||
DEFAULT_MINDMAP_THEME,
|
||||
defaultMindmapData,
|
||||
type MindMapData,
|
||||
} from "./mindmap-projection";
|
||||
@@ -21,18 +24,40 @@ export type SimpleMindMapInstance = {
|
||||
execCommand?: (command: string, ...args: unknown[]) => unknown;
|
||||
destroy?: () => void;
|
||||
getData?: (withConfig?: boolean) => unknown;
|
||||
getLayout?: () => unknown;
|
||||
getTheme?: () => unknown;
|
||||
getThemeConfig?: (prop?: unknown) => unknown;
|
||||
on?: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
off?: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
setData?: (data: unknown) => void;
|
||||
setLayout?: (layout: unknown, notRender?: boolean) => void;
|
||||
setMode?: (mode: "edit" | "readonly") => void;
|
||||
setTheme?: (theme: unknown, notRender?: boolean) => void;
|
||||
setThemeConfig?: (config: Record<string, unknown>, notRender?: boolean) => void;
|
||||
updateData?: (data: unknown) => void;
|
||||
updateConfig?: (config?: Record<string, unknown>) => void;
|
||||
view?: {
|
||||
scale?: number;
|
||||
enlarge?: () => void;
|
||||
narrow?: () => void;
|
||||
getTransformData?: () => unknown;
|
||||
setScale?: (scale: number, cx: number, cy: number) => void;
|
||||
fit?: () => void;
|
||||
};
|
||||
renderer?: {
|
||||
setRootNodeCenter?: () => void;
|
||||
textEdit?: {
|
||||
hideEditTextBox?: () => void;
|
||||
isShowTextEdit?: () => boolean;
|
||||
};
|
||||
clearActiveNodeList?: () => void;
|
||||
addNodeToActiveList?: (node: unknown, skipBeforeEvent?: boolean) => void;
|
||||
emitNodeActiveEvent?: (node?: unknown, activeNodeList?: unknown[]) => void;
|
||||
findNodeByUid?: (uid: string) => unknown;
|
||||
activeNodeList?: unknown[];
|
||||
lastActiveNodeList?: unknown[];
|
||||
root?: unknown;
|
||||
renderTree?: { _node?: unknown };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,6 +87,7 @@ export type SimpleMindMapBridge = {
|
||||
instance: SimpleMindMapInstance;
|
||||
execCommand: (command: string, ...args: unknown[]) => SimpleMindMapSafeCommandResult;
|
||||
getSnapshot: () => unknown;
|
||||
refreshProjection?: (reason?: string) => Promise<void>;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
@@ -125,6 +151,11 @@ const readRecord = (value: unknown): Record<string, unknown> => {
|
||||
return {};
|
||||
};
|
||||
|
||||
const readThemeConfig = (value: unknown): Record<string, unknown> => {
|
||||
if (!isRecord(value) || Object.keys(value).length === 0) return createDefaultMindmapThemeConfig();
|
||||
return value;
|
||||
};
|
||||
|
||||
const readViewData = (value: unknown): Record<string, unknown> | null => {
|
||||
if (!isRecord(value) || !isRecord(value.state)) return null;
|
||||
return {
|
||||
@@ -153,9 +184,9 @@ export const buildSimpleMindMapOptions = (input: {
|
||||
: typeof config.fit === "boolean"
|
||||
? config.fit
|
||||
: true,
|
||||
layout: readString(input.projection.layout, "logicalStructure"),
|
||||
theme: readString(input.projection.theme, "classic"),
|
||||
themeConfig: readRecord(input.projection.themeConfig),
|
||||
layout: readString(input.projection.layout, DEFAULT_MINDMAP_LAYOUT),
|
||||
theme: readString(input.projection.theme, DEFAULT_MINDMAP_THEME),
|
||||
themeConfig: readThemeConfig(input.projection.themeConfig),
|
||||
viewData: readViewData(input.projection.view),
|
||||
initRootNodePosition: ["center", "center"],
|
||||
};
|
||||
@@ -241,7 +272,9 @@ export const attachSimpleMindMapEventListeners = (input: {
|
||||
SIMPLE_MIND_MAP_BRIDGE_EVENTS.forEach((eventName) => {
|
||||
const handler = (...args: unknown[]) => {
|
||||
const snapshot =
|
||||
eventName === "data_change" || eventName === "view_data_change"
|
||||
eventName === "data_change"
|
||||
? input.instance.getData?.()
|
||||
: eventName === "view_data_change"
|
||||
? input.instance.getData?.(true) ?? input.instance.getData?.()
|
||||
: undefined;
|
||||
input.onEvent?.({
|
||||
@@ -266,12 +299,14 @@ export const createSimpleMindMapBridge = async (input: {
|
||||
onEvent?: (event: SimpleMindMapBridgeEvent) => void;
|
||||
}): Promise<SimpleMindMapBridge> => {
|
||||
const runtime = await loadSimpleMindMapRuntime(input.pluginNames);
|
||||
const themeConfig = readThemeConfig(input.projection.themeConfig);
|
||||
const options = buildSimpleMindMapOptions({
|
||||
el: input.el,
|
||||
projection: input.projection,
|
||||
runtimeOptions: input.runtimeOptions,
|
||||
});
|
||||
const instance = new runtime.MindMap(options);
|
||||
instance.setThemeConfig?.(themeConfig);
|
||||
if (input.mode) instance.setMode?.(input.mode);
|
||||
|
||||
const detachEvents = attachSimpleMindMapEventListeners({
|
||||
@@ -286,6 +321,7 @@ export const createSimpleMindMapBridge = async (input: {
|
||||
execCommand,
|
||||
getSnapshot: () => instance.getData?.(true) ?? instance.getData?.(),
|
||||
destroy: () => {
|
||||
instance.renderer?.textEdit?.hideEditTextBox?.();
|
||||
detachEvents();
|
||||
instance.destroy?.();
|
||||
},
|
||||
@@ -300,9 +336,9 @@ export const createFallbackMindmapAdapterProjection = (input: {
|
||||
schema: "mnote.mindmap.simple_mind_map_scene.v1",
|
||||
runtime: "simple-mind-map",
|
||||
root: input.root ?? defaultMindmapData,
|
||||
layout: "logicalStructure",
|
||||
theme: "classic",
|
||||
themeConfig: {},
|
||||
layout: DEFAULT_MINDMAP_LAYOUT,
|
||||
theme: DEFAULT_MINDMAP_THEME,
|
||||
themeConfig: createDefaultMindmapThemeConfig(),
|
||||
view: {},
|
||||
config: {},
|
||||
compatPayload: { source: "frontend-fallback", mindmapId: input.mindmapId },
|
||||
|
||||
@@ -14,14 +14,37 @@ describe("onlyoffice client session helpers", () => {
|
||||
expect(docTypeFromExt("docx")).toBe("word");
|
||||
expect(docTypeFromExt("xlsx")).toBe("cell");
|
||||
expect(docTypeFromExt("pptx")).toBe("slide");
|
||||
expect(docTypeFromExt("pdf")).toBe("pdf");
|
||||
expect(docTypeFromExt("pdf")).toBe("word");
|
||||
});
|
||||
|
||||
it("inferOnlyOfficeFileType detects Office assets from file name and MIME", () => {
|
||||
it("inferOnlyOfficeFileType detects only true Office assets from file name and MIME", () => {
|
||||
expect(inferOnlyOfficeFileType("demo.pptx", null)).toBe("pptx");
|
||||
expect(inferOnlyOfficeFileType("demo", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe("docx");
|
||||
expect(inferOnlyOfficeFileType("demo", "application/pdf")).toBe("pdf");
|
||||
expect(inferOnlyOfficeFileType("demo", "application/pdf")).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.png", "image/png")).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.pdf", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.toml", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.json", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.yaml", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.yml", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.md", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.txt", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.ts", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.js", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.py", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.rs", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.go", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.html", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.css", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.vue", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.svelte", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.proto", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.graphql", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.gradle", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.tf", null)).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("demo.pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBeNull();
|
||||
expect(inferOnlyOfficeFileType("Dockerfile", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBeNull();
|
||||
expect(inferOnlyOfficeFileType(".gitignore", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBeNull();
|
||||
});
|
||||
|
||||
it("buildOnlyOfficeAssetOpenUrl keeps attachment identity for callback writeback", () => {
|
||||
|
||||
@@ -22,11 +22,9 @@ export const docTypeFromExt = (ext: string) => {
|
||||
const word = ["doc", "docx", "odt", "rtf"];
|
||||
const slide = ["ppt", "pptx", "odp"];
|
||||
const sheet = ["xls", "xlsx", "ods", "csv"];
|
||||
const pdf = ["pdf"];
|
||||
if (word.includes(ext)) return "word";
|
||||
if (slide.includes(ext)) return "slide";
|
||||
if (sheet.includes(ext)) return "cell";
|
||||
if (pdf.includes(ext)) return "pdf";
|
||||
return "word";
|
||||
};
|
||||
|
||||
@@ -34,14 +32,84 @@ export const inferOnlyOfficeFileType = (fileName: string | null | undefined, mim
|
||||
const name = (fileName ?? "").trim().toLowerCase();
|
||||
const mt = (mimeType ?? "").trim().toLowerCase();
|
||||
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||
if (["doc", "docx", "odt", "rtf"].includes(ext)) return ext;
|
||||
if (["ppt", "pptx", "odp"].includes(ext)) return ext;
|
||||
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return ext;
|
||||
if (ext === "pdf") return ext;
|
||||
const officeExts = ["doc", "docx", "odt", "rtf", "ppt", "pptx", "odp", "xls", "xlsx", "ods", "csv"];
|
||||
const nonOfficeFileNames = [
|
||||
".dockerignore",
|
||||
".editorconfig",
|
||||
".env",
|
||||
".eslintrc",
|
||||
".gitattributes",
|
||||
".gitignore",
|
||||
".npmrc",
|
||||
".prettierrc",
|
||||
"cmakelists.txt",
|
||||
"dockerfile",
|
||||
"gemfile",
|
||||
"makefile",
|
||||
"procfile",
|
||||
"rakefile",
|
||||
];
|
||||
const nonOfficeExts = [
|
||||
"pdf",
|
||||
"toml",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"md",
|
||||
"markdown",
|
||||
"txt",
|
||||
"ini",
|
||||
"env",
|
||||
"xml",
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"py",
|
||||
"rs",
|
||||
"go",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"vue",
|
||||
"svelte",
|
||||
"astro",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"h",
|
||||
"hpp",
|
||||
"cs",
|
||||
"php",
|
||||
"rb",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"sql",
|
||||
"lock",
|
||||
"log",
|
||||
"proto",
|
||||
"graphql",
|
||||
"gql",
|
||||
"prisma",
|
||||
"tf",
|
||||
"tfvars",
|
||||
"hcl",
|
||||
"nix",
|
||||
"gradle",
|
||||
];
|
||||
if (officeExts.includes(ext)) return ext;
|
||||
if (nonOfficeFileNames.includes(name)) return null;
|
||||
if (nonOfficeExts.includes(ext)) return null;
|
||||
if (mt.includes("wordprocessingml")) return "docx";
|
||||
if (mt.includes("presentationml")) return "pptx";
|
||||
if (mt.includes("spreadsheetml")) return "xlsx";
|
||||
if (mt.includes("pdf")) return "pdf";
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user