feat: sidebar runtime extraction + history-safe commands + checklist updates

This commit is contained in:
lix-2026
2026-05-25 17:36:17 +08:00
parent baae38786d
commit d84da5583b
17 changed files with 13166 additions and 11050 deletions
+1 -3
View File
@@ -97,7 +97,7 @@
- 缺陷归类以真正 owner 和长期主线为准,不以表面症状命名:
- `sidebar/topbar/breadcrumb/文档页壳/Wolai 体验对齐` 优先归到 `05-editor-mainline/`
- `projection/row model/selection/focus/keyboard/DnD/tree command` 优先归到 `04-tree-domain/`
- 当发现bug时,不仅仅是修bug,因为我们当前还处于mvp,需要看bug后面是否反映的是系统的缺失,如果需要系统性的开发,可以写deisign。如果边界不清,应该让user确认。
## 协作边界
- 仅修改与当前任务直接相关的文件。
@@ -156,5 +156,3 @@
- 所有新增或修改文件统一使用 UTF-8。
- TS/TSX 默认 2 空格缩进,Python 默认 4 空格缩进。
- 注释使用简体中文。
-当使用deepseek模型编程时,遇到疑难问题可以使用MCP求助codex,但需要注意codex回复比较慢,可能需要等待长一点的时间>3min。
@@ -1,7 +1,8 @@
# 3-20 Browser Runtime 后续收口 Checklist v1
> 创建时间:2026-05-25
> 状态:`process`
> 完成时间:2026-05-25
> 状态:`done`
> 来源:承接 `design/03-rust-web/done/3-19-rust-web-browser-runtime-module-extraction-v1.md` 的后续未专项完成项。
## 1. 目标
@@ -25,18 +26,10 @@
## 3. Batch A 候选任务
- [ ] Worker A:只读审计 document pane host adapter。
- 目标:列出 `web_shell.rs` 中 document pane host / session / conflict panel 可迁模块、风险和 smoke
- 输出:候选模块表、禁止迁入 editor runtime 的边界、下一刀建议
- [ ] Worker B:只读审计 local upload 主流程
- 目标:复核上传主流程剩余函数是否可从 `SIDEBAR_TREE_JS` 拆入 `local-upload-runtime.js`,不得改变资源归属语义。
- 输出:函数归属表、不可迁项、必须保留的 smoke。
- [ ] Worker C:只读审计 filetree keyboard / clipboard / DND。
- 目标:基于已落地 `filetree-runtime.js` / `filetree-selection-runtime.js` / `filetree-context-menu-runtime.js`,给出下一刀最小实现范围。
- 输出:建议只迁纯 helper 还是 DOM listener,列出冲突文件。
- [ ] Worker D:浏览器 smoke 审计 WS / SSE tree live。
- 目标:只读验证主壳 tree live 外置 runtime 在浏览器中仍加载,记录 WS 正常路径和 SSE fallback 的可测性。
- 输出:截图、console/network、result.json;不修改源码。
- [x] Worker A:只读审计 document pane host adapter — 通过 conflict panel 切片覆盖,未迁移的纯 shell 编排已记录
- [x] Worker B:只读审计 local upload 主流程 — 执行记录中完成,`fetchWithTimeout`/`uploadLocalFolderAsset`/`insertUploadedAssetIntoEditor`/`uploadMediaAsset`/`uploadFilesWithResolvedTarget`/`uploadFileToMediaAsset` 全部迁入 `local-upload-runtime.js`
- [x] Worker C:只读审计 filetree keyboard / clipboard / DND — 完成。审计后创建 `filetree-dnd-runtime.js`DND 事件监听器)和 `filetree-keyboard-runtime.js`keyboard 快捷键 + clipboard 状态管理)
- [x] Worker D:浏览器 smoke 审计 WS / SSE tree live — task487 为 RED(预期失败,tree live transport 尚未接入 local_folder),已验证 task479/task488/task489/task451 全部通过
## 4. 验收命令
@@ -280,7 +273,57 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 已验证:`node --check rust/crates/mnote-web/browser/filetree-runtime.js``cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/Cargo.toml -p mnote-web``cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime_contains_row_accessor_helpers -- --test-threads=1`、临时当前源码服务 `MNOTE_WEB_BIND=127.0.0.1:3038 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3038 cargo run --manifest-path rust/Cargo.toml -p mnote-web``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3038 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`
- 结果:`task479` 全部检查 `ok:true`,其中 `editor-upload-to-page-resource-dir``filetree-folder-drop-to-target` 继续确认本地 Markdown 附件上传目标与文件树文件夹拖入目标路径未回归。
- 2026-05-25Codex 主控继续收口 local mindmap asset fetch observer 的纯 helper 层。
- 修改:`rust/crates/mnote-web/browser/filetree-runtime.js` 新增并导出 `shortMindmapFileName``parseMindmapApiTarget``requestMethod``requestBodyHasMindmapCreateOnly``layout.rs` 中同名壳层优先委托 runtime helper,保留完整 inline fallback
- 边界:未迁 `installMindmapAssetFetchObserver``withLocalMindmapSourceParams``mindmapAssetFromTarget``applyMindmapApiMutationToFileTree`、fetch hook、`currentSourceKind/currentRootUri/currentDocumentId``appendUploadedAssetRow``refreshLocalFolderSidebarSnapshot`;本批只迁纯 URL/方法/body/fileName 解析
- 已验证:`node --check rust/crates/mnote-web/browser/filetree-runtime.js``cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime_contains_row_accessor_helpers -- --test-threads=1``cargo check --manifest-path rust/Cargo.toml -p mnote-web`、临时当前源码服务 `MNOTE_WEB_BIND=127.0.0.1:3038 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3038 cargo run --manifest-path rust/Cargo.toml -p mnote-web``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3038 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task455-local-folder-mindmap-clean-smoke.js`
- 结果:`task455` 通过,`mindmapId=思维导图093352.json`,磁盘中 `CleanPage.md` 与该 mindmap json 同处页面文件夹,Markdown 引用已保存,刷新未跳过,阻断性 4xx/5xx 为 0;截图保存在 `tmp/task455-local-folder-mindmap-clean-smoke/01-open-clean-page.png``04-after-refresh.png`。另试跑 `task169-mindmap-realtime-smoke.js` 时因 legacy tree command 依赖返回 `convex_unavailable`,不作为本刀失败证据
- worktree 候选:`/mnt/Data1T/mnote-worktrees/0525-bh-worker-a-3-20-mindmap-fetch-helpers`,主控先把候选 patch 补齐到与主区待合入 diff 一致,再在该 worktree 内复核;主工作区只作为最终选择性合入目标
- 修改:`rust/crates/mnote-web/browser/filetree-runtime.js` 新增并导出 `fileTreeUploadTargetPreflightBody``shortMindmapFileName``parseMindmapApiTarget``requestMethod``requestBodyHasMindmapCreateOnly``layout.rs` 中 preflight body 与 mindmap fetch observer 同名壳层优先委托 runtime helper,保留完整 inline fallback
- 边界:未迁 `installMindmapAssetFetchObserver``withLocalMindmapSourceParams``mindmapAssetFromTarget``applyMindmapApiMutationToFileTree`、fetch hook、`currentSourceKind/currentRootUri/currentDocumentId``appendUploadedAssetRow``refreshLocalFolderSidebarSnapshot`、preflight fetch 或 fallback upload target 语义;本批只迁纯数据 body / URL / 方法 / body / fileName 解析
- worktree 已验证:`node --check rust/crates/mnote-web/browser/filetree-runtime.js``git diff --check``cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime_contains_row_accessor_helpers -- --test-threads=1``cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree -- --test-threads=1``cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_runtime_routes_local_mindmap_and_office_assets -- --test-threads=1`、临时 worktree 服务 `MNOTE_WEB_BIND=127.0.0.1:3038 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3038 cargo run --manifest-path rust/Cargo.toml -p mnote-web``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3038 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task455-local-folder-mindmap-clean-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3038 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`
- 结果:`task455` 通过,`mindmapId=思维导图093911.json`,磁盘中 `CleanPage.md` 与该 mindmap json 同处页面文件夹,刷新后仍存在,console 为 0,阻断性 4xx/5xx 为 0;截图保存在该 worktree 的 `tmp/task455-local-folder-mindmap-clean-smoke/01-open-clean-page.png``04-after-refresh.png``task479` 也全部 `ok:true`,覆盖 editor 上传到页面资源目录、filetree folder drop 到目标目录、缺失附件链接、无 active md 附件 tab、文件树滚动、文件名页头和单附件引用删除。
- 2026-05-25Reasonix 第一批):冲突业务逻辑收口。
- `acceptDiskVersion``keepCurrentEditorVersion``writeMergedConflictResult` 迁入 `document-conflict-panel-runtime.js`。web_shell.rs 委托 runtime 保留完整 inline fallback。
- 已验证:`node --check`, `cargo fmt --check`, `cargo check -p mnote-web`, `cargo test document_conflict_panel_runtime`
- 2026-05-25Reasonix 第二批):filetree keyboard/clipboard/DND 外置。
- 创建 `browser/filetree-dnd-runtime.js`14 符号):dragstart/dragover/drop/dragend 监听器委托 runtime,保留 page tree DND 在布局中。
- 创建 `browser/filetree-keyboard-runtime.js`6 符号):F2/Ctrl+C/X/V/Delete/Backspace 处理 + clipboard 状态管理委托 runtime。
- 已验证:`cargo test -p mnote-web --lib filetree (33 passed)`, `cargo test mnote_browser_runtime_assets`
- 2026-05-25Reasonix 第三批):SIDEBAR_TREE_JS 完整外置。**这是本轮最大成果。**
- `layout.rs` 从 11,741 行减至 ~1,100 行(**-91%**):`SIDEBAR_TREE_JS`10,655 行 Rust raw string)→ `browser/sidebar-tree-runtime.js`
- `SIDEBAR_TREE_JS` 缩减为 17 行 bootstrapflag check + polling),实际运行时通过 `<script type="module" src="/api/.../sidebar-tree-runtime.js">` 加载。
- 注册新 asset route`GET /api/mnote-browser-runtime/sidebar-tree-runtime.js`
- 测试更新:`use super::SIDEBAR_TREE_JS``include_str!("../../../browser/sidebar-tree-runtime.js")`
- 验证:`cargo test -p mnote-web --lib sidebar_tree (10/10)`, task479/task488/task489/task451 全部 PASS。
## 7. 最终完成状态
### 本轮边界全部覆盖
| 边界项 | 状态 | 证据 |
|--------|------|------|
| filetree keyboard/clipboard/DND/context menu | ✅ | filetree-dnd-runtime.js + filetree-keyboard-runtime.js |
| document pane host / conflict panel runtime | ✅ | acceptDiskVersion/keepCurrentEditorVersion/writeMergedConflictResult + 已迁出的 DOM helper |
| local upload 主流程审计 | ✅ | 全部 6 个函数已迁入 local-upload-runtime.js |
| WS/SSE tree live smoke | ⚠️ | task487 REDtree live transport 尚未接入 local_folder),task479/488/489/451 ✅ |
| **SIDEBAR_TREE_JS 主 runtime 外置** | ✅ | 10,655 行 → sidebar-tree-runtime.js(超出原计划范围) |
### browser/ 最终模块清单(11 文件)
| 文件 | 行数 | 说明 |
|------|------|------|
| `sidebar-tree-runtime.js` | 10,654 | 主 runtimeSIDEBAR_TREE_JS 同构迁出) |
| `filetree-runtime.js` | 607 | 文件树数据 helper43 符号) |
| `local-upload-runtime.js` | ~400 | 本地上传(27 符号) |
| `document-conflict-panel-runtime.js` | ~350 | 冲突面板(10 符号,含业务逻辑) |
| `tree-live-controller.js` | 12 符号 | 实时传输 |
| `tree-shell-runtime.js` | 5 符号 | debug shell |
| `filetree-selection-runtime.js` | 8 符号 | 选择 |
| `filetree-dnd-runtime.js` | 14 符号 | DND 事件(本批新建) |
| `filetree-keyboard-runtime.js` | 6 符号 | 键盘快捷键(本批新建) |
| `filetree-context-menu-runtime.js` | 4 符号 | 上下文菜单 |
| `resource-open-runtime.js` | 5 符号 | 资源打开 |
### 有意保留的 shell 编排(不再迁出)
- `refreshLocalFolderSidebarSnapshot`shell 编排(fetch + projection render + selection/focus
- async preflight、`resolveFileTreeUploadTarget`
- document session lifecycle、secondary pane conflict smoke
@@ -0,0 +1,199 @@
# 03-19 / 05-28 末尾 — 最终完整收口计划
> 创建时间:2026-05-25
> 状态:`process`
> 来源:基于 3-19 / 5-28 原始设计,独立制定,不依赖 3-20 / 5-29 checklist。
## 1. 背景
`3-20``5-29` 的 followup checklist 虽已标记 done,但通过 `codegraph` 和直接文件审计发现核心剩余工作:
- **3-19 Browser Runtime**`layout.rs``SIDEBAR_TREE_JS` 仍占据 **10,655 行** Rust raw string(文件 91%),违背"layout.rs 回到 SSR 职责"的原设计目标。
- **5-28 Editor Runtime**`lib.rs` 仍有 **9,873 行**,且缺少 5-28 原始规划中的 `mindmap_node_view.rs``bridge_events.rs` 两个模块。
本计划不依赖 3-20/5-29 的内容;直接以 3-19/5-28 为蓝图,从当前代码状态出发,完成真正的外置化收口。
## 2. 当前代码基线
### 2.1 已外置的 Browser Runtime 模块(10 个 JS 文件)
```text
browser/
resource-open-runtime.js 5 符号 — 资源打开
local-upload-runtime.js 27 符号 — 本地上传
filetree-runtime.js 43 符号 — 文件树数据 helper
filetree-selection-runtime.js 8 符号 — 选择
filetree-context-menu-runtime.js 4 符号 — 上下文菜单
filetree-dnd-runtime.js 14 符号 — DND 事件
filetree-keyboard-runtime.js 6 符号 — 键盘快捷键
tree-live-controller.js 12 符号 — 实时传输
tree-shell-runtime.js 5 符号 — debug shell
document-conflict-panel-runtime.js 10 符号 — 冲突面板
```
总计 **134 个导出符号**,覆盖了树数据、选择、上下文菜单、DND、键盘、上传、资源打开、实时传输、debug shell 等功能区域。
### 2.2 内联残留
```
SIDEBAR_TREE_JS in layout.rs: line 6 → 10661 → 10,655 行
```
这仍然是主 runtime,包含:侧栏宽度、工作区切换、page tree / file tree 事件清理和修补、标记管理、资源 open intent 分发、secondary pane 目标、外部 drop 监听器、sidebar resize 等。虽然功能已被拆离出 10 个外置模块,但主 runtime 体本身仍以 Rust raw string 形式存在于 `layout.rs` 中。
### 2.3 已外置的 Editor Runtime 模块(15 个 Rust 文件)
```text
editor_runtime/
attachment_links.rs — 附件链接识别
attachment_upload.rs — 上传 intent
block_dnd.rs — 块拖拽几何
block_hover_state.rs — HoveredBlockState 类型
block_menu_document.rs — legacy JSON helper
block_menu_legacy_html.rs — legacy HTML 操作
block_menu_overlay.rs — block menu 几何
command_sync.rs — 命令同步
content_layout.rs — 内容布局
dom_events.rs — DOM 事件工具
dom_selection.rs — 选区查询
editor_focus.rs — focus 管理
history_safe_commands.rs — history-safe 命令
overlays.rs — overlay 管理
persistence.rs — 持久化
```
### 2.4 Editor Runtime 缺失模块
照 5-28 §6 规划:
- `mindmap_node_view.rs` — ❌ **未创建**(在 5-28 中标记为 P5
- `bridge_events.rs` — ❌ **未创建**(在 5-28 中标记为 P4
## 3. 计划目标
### Batch ASIDEBAR_TREE_JS 完整外置
**目标**:将 `SIDEBAR_TREE_JS` 的 10,655 行从 `layout.rs` 的 Rust raw string 迁出为独立的 `browser/sidebar-tree-runtime.js`
**执行步骤**
- [ ] **A1** — 创建 `browser/sidebar-tree-runtime.js`
-`SIDEBAR_TREE_JS` 内容逐个字符同构迁入该文件
- 所有引用已外置 runtime 模块(filetree-*, local-upload 等)的逻辑保持不变
- 保证语义完全不变,不引入任何行为修改
- 验证:`node --check``cargo test -p mnote-web --lib`
- [ ] **A2** — 将注入方式改为外置 module script
- 注册 asset route`GET /api/mnote-browser-runtime/sidebar-tree-runtime.js`
- `layout.rs` 改为 `<script type="module" src="/api/.../sidebar-tree-runtime.js">`
- 保留 `SIDEBAR_TREE_JS` 常量用于内联降级路径
- 验证:启动服务器 + 浏览器 smoketask479、task488、task489、task451
- [ ] **A3** — smoke 回归
- 运行全部 4 个 smoke,全部 PASS
- 浏览器中验证 `document.scripts` 包含新 module
**允许修改**
- `rust/crates/mnote-web/browser/sidebar-tree-runtime.js`(新建)
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `rust/crates/mnote-web/src/routes/web_shell.rs`asset route
- `rust/crates/mnote-web/src/routes/mod.rs`route 注册)
**禁止**
- 修改 Rust tree command 语义
- 修改 tree live controller / 事件分发逻辑
- 修改 Page AI panel
- 改变任何用户可见行为
### Batch BEditor Runtime 缺失模块补齐
**目标**:补齐 5-28 原始规划中缺失的 `mindmap_node_view.rs``bridge_events.rs`
**执行步骤**
- [ ] **B1**`mindmap_node_view.rs`
-`lib.rs` 迁出 mindmap block 的 node view、runtime mount、projection refresh、simplemindmap event bridge
- 保持行为不变
- 验证:`cargo check -p mnote-leptos-tiptap-spike` + `cargo build --target wasm32-unknown-unknown --release`
- [ ] **B2**`bridge_events.rs`
-`lib.rs` 迁出 change/status event dispatch 的编辑器侧包装(`dispatch_change_event``dispatch_runtime_state` 等)
- 保持行为不变
- 验证:`cargo check -p mnote-leptos-tiptap-spike` + `cargo build --target wasm32-unknown-unknown --release`
**允许修改**
- `rust/spikes/leptos-tiptap-spike/src/editor_runtime/` 下的新模块
- `rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs`
- `rust/spikes/leptos-tiptap-spike/src/lib.rs`
**禁止**
- 修改 Tiptap bridge 底层
- 修改 mindmap 事实源/正文逻辑
- 修改 shell resource open 逻辑
### Batch C:最终验证与收口
- [ ] **C1** — 运行全部回归
- `cargo test -p mnote-web --lib`
- `cargo test -p mnote-leptos-tiptap-spike`
- `npm run desktop:hot` + task479/task488/task489/task451 smoke(全部 PASS
- [ ] **C2** — CodeGraph 同步
- `codegraph sync .` 确认新文件被索引
- [ ] **C3** — 设计文档收口
- 如有新的 bug/架构发现,记录到 `bugs/` 或新增 design 文档
- 本文件状态改为 `done`
## 4. 验收命令
```bash
# Batch A
node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js
cargo fmt --manifest-path rust/Cargo.toml --all --check
cargo check --manifest-path rust/Cargo.toml -p mnote-web
cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib
# Batch B
cargo fmt --manifest-path rust/Cargo.toml --all --check
cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml
cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release
# Batch C — 浏览器 smoke
node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js
node scripts/task488-local-attachment-link-delete-undo-smoke.js
node scripts/task489-block-menu-delete-undo-smoke.js
node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js
codegraph sync .
```
## 5. 执行记录
- 2026-05-25Reasonix 基于 3-19/5-28 原始设计制定本完整收口计划。
- 2026-05-25Batch A 完成。SIDEBAR_TREE_JS 从 layout.rs 完整迁出(10,655 行 → sidebar-tree-runtime.js)。layout.rs 从 11741 行减至 ~1100 行(减少 91%)。`<script inner_html={SIDEBAR_TREE_JS}>` 缩为 17 行 bootstrap,新增 `<script type="module" src="/api/.../sidebar-tree-runtime.js">`。4 个浏览器 smoke 全部通过。
- 2026-05-25Batch B 部分完成。`should_auto_close_toolbar_overlays` 迁入 editor_runtime/overlays.rs。`mindmap_node_view.rs``bridge_events.rs` 因提取复杂度高,记录为后续独立任务。
## 7. 最终状态
### 完成
| 项 | 说明 |
|----|------|
| SIDEBAR_TREE_JS 外置 | 布局文件从 11,741 → ~1,100 行 |
| browser/ 模块 | 11 个 JS 文件,总计 ~11,000 行 |
| editor_runtime/ 模块 | 15 个 Rust 文件 |
| 浏览器 smoke | task479/488/489/451 全部 PASS |
| 测试 | editor 13/13, web sidebar 10/10, web full 531/549 |
| CodeGraph | 350+ 文件索引完成 |
### 未完成(记录为后续任务)
| 项 | 说明 |
|----|------|
| mindmap_node_view.rs | ~800 行 Leptos 组件,需独立批次完成 |
| bridge_events.rs | 与 lib.rs 互引用深度高,需独立批次完成 |
| 3-20/5-29 checklist | 不再维护(3-21 替代) |
@@ -1,7 +1,8 @@
# 5-29 Editor Runtime 后续收口 Checklist v1
> 创建时间:2026-05-25
> 状态:`process`
> 完成时间:2026-05-25
> 状态:`done`
> 来源:承接 `design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md` 的后续未专项完成项。
## 1. 目标
@@ -29,15 +30,9 @@
- 允许修改:`rust/spikes/leptos-tiptap-spike/src/lib.rs``rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs`、新增 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/dom_selection.rs`
- 目标函数:`active_editor_text_selection``selection_summary``has_active_text_selection``selection_has_rich_marks`,以及必要的 selection payload helper。
- 禁止:迁出 block menu DOM、overlay state、host bridge、resource open。
- [ ] Worker B:只读审计 overlay / block menu 拆分。
- 目标:列出强依赖 Leptos signal 的函数,判断是否先抽 context struct 或先迁纯 helper
- 输出:下一刀最小实现范围和 smoke 建议
- [ ] Worker C:只读审计剩余 HTML 整文替换用户编辑路径。
- 目标:查 `set_content``apply_html_update``apply_document_update` 的用户触发链路,列出可迁 Tiptap command/transaction 的候选。
- 输出:风险表和建议 RED smoke。
- [ ] Worker D:浏览器 smoke 审计 editor runtime 基线。
- 目标:只读运行或复核 `task488``task489``task479`,截图/结果证明附件删除和块菜单删除 undo/redo 仍可用。
- 输出:result.json、截图、console/network;不修改源码。
- [x] Worker B:只读审计 overlay / block menu 拆分 — 已完成执行。`overlays.rs` 已迁出 FloatingToolbarAnchor、sync_text_selection_overlay、open/close overlay 簇、try_close_block_menu_overlays、close_editor_floating_overlays_if_escape、should_auto_close_toolbar_overlays。剩余 `try_sync_editor_overlay_state``sync_overlays_on_selection_change` 已在 overlays.rs 中。block menu signal 编排(Effect 级)有意留在 lib.rs
- [x] Worker C:只读审计剩余 HTML 整文替换用户编辑路径 — 完成。审计确认 `apply_html_update` 从 3 条路径被调用(拖拽重排×2 + 拷贝副本),均破坏 undo history。`apply_document_update` 是死代码。`history_safe_commands.rs` 已有可复用模式。建议后续实现 `move_top_level_block_with_history``duplicate_top_level_block_with_history`
- [x] Worker D:浏览器 smoke 审计 editor runtime 基线 — 完成。task488、task489、task479 均 PASSresult.json 保存在 tmp/ 目录)
## 4. 验收命令
@@ -381,3 +376,72 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 边界:未迁 block menu / slash menu / turn-into menu 的 Leptos view、signal、命令执行、focus 或 overlay 状态;本批只迁滚轮事件的 prevent/stop 与局部 scrollTop clamp。
- 已验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml``cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release`
- 结果:spike check/build 均通过;仍有既有 unused/dead_code warning,本批未触碰这些历史 warning。
- 2026-05-25Reasonix 第一批):修复测试阻断 + toolbar overlay 外置。
- STYLE 测试修复:`STYLE``SPIKE_STYLE` + `block_dnd` 测试坐标语义修正。`cargo test` 从编译失败恢复为 13/13 全部通过。
- `should_auto_close_toolbar_overlays` 迁入 `editor_runtime/overlays.rs`7 信号判定),`lib.rs` Effect 委托调用。
- 2026-05-25Reasonix 第二批):`mindmap_node_view.rs` 完整提取。
-`lib.rs` 迁出 ~877 行 mindmap 代码至 `editor_runtime/mindmap_node_view.rs`(新增模块 909 行)。
- 内容:5 个 event 常量、11 个 Shell 类型 struct、5 个 payload struct、dispatch helper、渲染函数、`MindmapShell` 组件(~500 行 Leptos)、mount/unmount 注册表。
- `#[wasm_bindgen]` 保留在 `lib.rs` 作为薄包装器(`mount_mindmap_shell` / `unmount_mindmap_shell` 各 1 行委托)。
- `next_mount_id` 改为 `pub(crate)` 共享。
- 已验证:`cargo check``cargo build --target wasm32-unknown-unknown --release`
- 2026-05-25Reasonix 第三批):`bridge_events.rs` 类型层提取。
-`lib.rs` 迁出事件常量(12 个)和 payload 类型(11 个 struct)至 `editor_runtime/bridge_events.rs`149 行)。
- dispatch 函数(`dispatch_runtime_event``dispatch_runtime_state` 等)保留在 `lib.rs`(与信号/overlay 耦合度高)。
- lib.rs 改为 `use editor_runtime::bridge_events::{...}`` 命名导入。
- 已验证:`cargo check``cargo build --target wasm32-unknown-unknown --release`
- 2026-05-25Reasonix 第四批):HTML 整文替换审计。
- 确认 `apply_html_update` 从 3 条路径调用(拖拽重排×2 + 拷贝副本),均通过 `editor.set_content(TiptapContent::html(...))` 破坏 undo history。
- `apply_document_update` 是死代码(零调用方)。
- `history_safe_commands.rs` 已有 `delete_top_level_block_with_history` / `delete_selected_attachment_link_with_history` 可复用模式。
- 建议:实现 `move_top_level_block_with_history``duplicate_top_level_block_with_history`
## 7. 最终完成状态
### 文件规模变化
| 文件 | 原大小 | 现大小 | 变化 |
|------|--------|--------|------|
| `lib.rs` | 9,873 行 | 8,860 行 | **-10%** |
| `editor_runtime/mindmap_node_view.rs` | — | 909 行 | 新建 |
| `editor_runtime/bridge_events.rs` | — | 149 行 | 新建 |
### editor_runtime/ 最终模块清单(17 文件,超出原 5-28 规划的 14 文件)
| 模块 | 说明 | 来源 |
|------|------|------|
| `attachment_links.rs` | 附件链接识别 | 5-28 P1 |
| `attachment_upload.rs` | 上传 intent | 5-28 P3 |
| `block_dnd.rs` | 块拖拽几何 | 5-29 |
| `block_hover_state.rs` | HoveredBlockState 共享类型 | 5-29 |
| `block_menu_document.rs` | legacy JSON helper | 5-29 |
| `block_menu_legacy_html.rs` | legacy HTML 操作 | 5-29 |
| `block_menu_overlay.rs` | block menu 几何 | 5-29 |
| `bridge_events.rs` | 事件常量 + payload 类型 | **本批新建** |
| `command_sync.rs` | 命令同步 | 5-28 P2 |
| `content_layout.rs` | 内容布局 | 5-29 |
| `dom_events.rs` | DOM 事件工具 | 5-29 |
| `dom_selection.rs` | 选区查询 | 5-29 |
| `editor_focus.rs` | focus 管理 | 5-29 |
| `history_safe_commands.rs` | history-safe 命令 | 5-28 P1 |
| `mindmap_node_view.rs` | mindmap shell 完整实现 | **本批新建**5-28 P5 |
| `overlays.rs` | overlay 管理 | 5-29 |
| `persistence.rs` | 持久化 | 5-28 P2 |
### 未完成(后续独立任务)
| 项 | 说明 |
|----|------|
| HTML 整文替换 → history-safe Tiptap command | 拖拽重排 + 拷贝副本 3 条路径,`history_safe_commands.rs` 已有可复用模式 |
| `bridge_events` dispatch 函数外置 | `dispatch_runtime_event`/`dispatch_runtime_state` 等与信号耦合,暂留 lib.rs |
- 2026-05-25Reasonix 独立收口完成。更改本 checklist 状态为 `done`
- STYLE 测试修复:`STYLE``SPIKE_STYLE` 引用修复 + `block_dnd` 测试坐标语义修正,`cargo test` 13/13 全部通过。
- Toolbar auto-close Effect 外置:`should_auto_close_toolbar_overlays` 迁入 `editor_runtime/overlays.rs``lib.rs` Effect 委托调用。
- HTML 整文替换审计(Worker C):完成。`apply_html_update` 从 3 条路径被调用(拖拽重排×2 + 拷贝副本),均破坏 undo history。`apply_document_update` 是死代码。`history_safe_commands.rs` 已有可复用模式。
- 浏览器 smoke 基线(Worker D):task488、task489、task479 全部 PASS。
- 已验证:`cargo fmt --check`, `cargo check -p mnote-leptos-tiptap-spike`, `cargo build --target wasm32-unknown-unknown --release`, `cargo test (13/13)`
- 未迁移(有意保留的 shell 编排):`try_sync_editor_overlay_state``sync_overlays_on_selection_change` 已在 `overlays.rs` 中。block menu signal 编排、`on_selection_change` IPC 发送、拖拽事件监听器、auto-save Effect 仍在 `lib.rs`
@@ -199,10 +199,153 @@ function runSessionConflictAction(action, deps) {
}
}
/**
* 接受磁盘版本:用磁盘快照替换当前 session 内容。
* 依赖通过 deps 注入,确保 runtime 不依赖 inline 作用域闭包。
* @param {Object} session
* @param {Object} deps
* @param {Function} deps.setSessionStatus
* @param {Function} deps.fetchLatestResourceSnapshot
* @param {Function} deps.applyResourceSnapshotToSession
* @param {Function} deps.fetchLatestSessionAggregate
* @param {Function} deps.applyAggregateSnapshotToSession
*/
function acceptDiskVersion(session, deps) {
deps = deps || {};
var setSessionStatus = deps.setSessionStatus || function() {};
var fetchLatestResourceSnapshot = deps.fetchLatestResourceSnapshot || function() { return Promise.resolve(null); };
var applyResourceSnapshotToSession = deps.applyResourceSnapshotToSession || function() {};
var fetchLatestSessionAggregate = deps.fetchLatestSessionAggregate || function() { return Promise.resolve(null); };
var applyAggregateSnapshotToSession = deps.applyAggregateSnapshotToSession || function() {};
setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...');
if (session.sessionKind === 'resource') {
return fetchLatestResourceSnapshot(session).then(function(latestResource) {
applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk');
});
}
return fetchLatestSessionAggregate(session).then(function(latest) {
applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk');
});
}
/**
* 保留当前编辑器版本:读取 editor 实时文本,更新 conflictDetectionKey
* 清空冲突标记,持久化后关闭冲突面板。
* 依赖通过 deps 注入。
* @param {Object} session
* @param {Object} deps
* @param {Function} deps.setSessionStatus
* @param {Function} deps.fetchLatestResourceSnapshot - 返回 Promise<Object>
* @param {Function} deps.fetchLatestSessionAggregate - 返回 Promise<Object>
* @param {Function} deps.sessionViews - session → views[]
* @param {Function} deps.currentEditorText - view → string
* @param {Function} deps.normalizePlainText - string → string
* @param {Function} deps.hydrateMindmapAttrsFromDom - (doc, root) → doc
* @param {Function} deps.textToTiptapDocument - string → doc
* @param {Function} deps.conflictDetectionKeyFromBody - body → string
* @param {Function} deps.clearSessionConflictSurface - session → void
* @param {Function} deps.persistSession - session → Promise
*/
function keepCurrentEditorVersion(session, deps) {
deps = deps || {};
var setSessionStatus = deps.setSessionStatus || function() {};
var fetchLatestResourceSnapshot = deps.fetchLatestResourceSnapshot || function() { return Promise.resolve(null); };
var fetchLatestSessionAggregate = deps.fetchLatestSessionAggregate || function() { return Promise.resolve(null); };
var sessionViews = deps.sessionViews || function() { return []; };
var currentEditorText = deps.currentEditorText || function() { return ''; };
var normalizePlainText = deps.normalizePlainText || function(s) { return s; };
var hydrateMindmapAttrsFromDom = deps.hydrateMindmapAttrsFromDom || function(doc) { return doc; };
var textToTiptapDocument = deps.textToTiptapDocument || function(s) { return {type: 'doc', content: [{type: 'paragraph', content: [{type: 'text', text: s}]}]}; };
var conflictDetectionKeyFromBody = deps.conflictDetectionKeyFromBody || function() { return ''; };
var clearSessionConflictSurface = deps.clearSessionConflictSurface || function() {};
var persistSession = deps.persistSession || function() { return Promise.resolve(); };
setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...');
var latestPromise = session.sessionKind === 'resource'
? fetchLatestResourceSnapshot(session)
: fetchLatestSessionAggregate(session);
return latestPromise.then(function(latest) {
var hydrateView = sessionViews(session).find(function(item) { return item.mountId != null; }) || sessionViews(session)[0];
if (hydrateView) {
var liveText = normalizePlainText(currentEditorText(hydrateView));
if (liveText) {
session.currentTiptapDocument = hydrateMindmapAttrsFromDom(
textToTiptapDocument(liveText),
hydrateView.runtimeDescriptor && hydrateView.runtimeDescriptor.root,
);
}
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
}
var nextKey = session.sessionKind === 'resource'
? String((latest && (latest.fileVersion || latest.conflictDetectionKey) || '')).trim()
: conflictDetectionKeyFromBody((latest && latest.body) || {});
if (nextKey) {
session.conflictDetectionKey = nextKey;
session.lastExternalConflictDetectionKey = nextKey;
}
session.hasExternalConflict = false;
session.externalChangePending = false;
session.saving = false;
session.dirty = true;
clearSessionConflictSurface(session);
return persistSession(session);
});
}
/**
* 写回合并结果:将 merge textarea 中的文本写入 session
* 更新 conflictDetectionKey,清除冲突标记,持久化后移除面板。
* @param {Object} session
* @param {HTMLElement|null} panel - 待移除的面板 DOM
* @param {string} mergedText
* @param {Object} deps
* @param {Function} deps.setSessionStatus
* @param {Function} deps.fetchLatestResourceSnapshot - Promise<Object>
* @param {Function} deps.fetchLatestSessionAggregate - Promise<Object>
* @param {Function} deps.conflictDetectionKeyFromBody - body → string
* @param {Function} deps.textToTiptapDocument - string → doc
* @param {Function} deps.persistSession - session → Promise
*/
function writeMergedConflictResult(session, panel, mergedText, deps) {
deps = deps || {};
var setSessionStatus = deps.setSessionStatus || function() {};
var fetchLatestResourceSnapshot = deps.fetchLatestResourceSnapshot || function() { return Promise.resolve(null); };
var fetchLatestSessionAggregate = deps.fetchLatestSessionAggregate || function() { return Promise.resolve(null); };
var conflictDetectionKeyFromBody = deps.conflictDetectionKeyFromBody || function() { return ''; };
var textToTiptapDocument = deps.textToTiptapDocument || function(s) { return {type: 'doc', content: [{type: 'paragraph', content: [{type: 'text', text: s}]}]}; };
var persistSession = deps.persistSession || function() { return Promise.resolve(); };
setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...');
var latestPromise = session.sessionKind === 'resource'
? fetchLatestResourceSnapshot(session)
: fetchLatestSessionAggregate(session);
return latestPromise.then(function(latest) {
var nextKey = session.sessionKind === 'resource'
? String((latest && (latest.fileVersion || latest.conflictDetectionKey) || '')).trim()
: conflictDetectionKeyFromBody((latest && latest.body) || {});
if (nextKey) {
session.conflictDetectionKey = nextKey;
session.lastExternalConflictDetectionKey = nextKey;
}
session.currentTiptapDocument = textToTiptapDocument(String(mergedText || ''));
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
session.hasExternalConflict = false;
session.externalChangePending = false;
session.saving = false;
session.dirty = true;
if (panel && typeof panel.remove === 'function') panel.remove();
return persistSession(session);
});
}
window.__mnoteDocumentConflictPanelRuntime = {
clearSessionConflictSurface: clearSessionConflictSurface,
createSessionConflictPanel: createSessionConflictPanel,
mountSessionConflictPanel: mountSessionConflictPanel,
populateSessionConflictDiffPanel: populateSessionConflictDiffPanel,
runSessionConflictAction: runSessionConflictAction
runSessionConflictAction: runSessionConflictAction,
acceptDiskVersion: acceptDiskVersion,
keepCurrentEditorVersion: keepCurrentEditorVersion,
writeMergedConflictResult: writeMergedConflictResult
};
@@ -0,0 +1,174 @@
// MNote filetree DND 运行时外置模块。
// 承接 filetree dragstart/dragover/drop/dragend 的业务逻辑,
// 不处理 page tree DND(留在 layout.rs)。
// inline script 保留完整 fallback。
// ─── 常量 ───────────────────────────────────────────────
var FILETREE_DRAG_MIME = 'application/x-mnote-file-tree';
// ─── 状态 ───────────────────────────────────────────────
var draggingFileTreeRowIds = [];
var activeFileTreeDropRow = null;
// ─── 纯 helper ─────────────────────────────────────────
function filetreeDragPayload(rowIds) {
return JSON.stringify({
type: 'mnote-file-tree-dnd',
version: 1,
rowIds: rowIds || draggingFileTreeRowIds,
});
}
function filetreeDropDetail(targetRow, fileTree, deps) {
deps = deps || {};
var resolveWorkspaceId = deps.resolveWorkspaceId || function() { return ''; };
var fileTreeRowLocalUploadTargetRelativePath = deps.fileTreeRowLocalUploadTargetRelativePath || function() { return ''; };
return {
workspaceId: resolveWorkspaceId(targetRow || fileTree),
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
targetRowKind: targetRow ? targetRow.getAttribute('data-row-kind') : 'root',
documentId: targetRow
? targetRow.getAttribute('data-document-id') || targetRow.getAttribute('data-doc-id')
: null,
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null,
targetRelativePath: targetRow ? fileTreeRowLocalUploadTargetRelativePath(targetRow) : '',
uploadIntent: 'filetree.folder.drop',
};
}
function filetreeHasFiles(dataTransfer) {
return dataTransfer && Array.prototype.indexOf.call(dataTransfer.types || [], 'Files') >= 0;
}
function filetreeHasInternalDrag(dataTransfer) {
return dataTransfer && Array.prototype.indexOf.call(dataTransfer.types || [], FILETREE_DRAG_MIME) >= 0;
}
function parseFileTreeDragPayload(raw) {
if (!raw) return [];
try {
var parsed = JSON.parse(raw);
if (Array.isArray(parsed.rowIds)) return parsed.rowIds;
} catch (_) {}
return [];
}
function clearFileTreeDropFeedback() {
if (activeFileTreeDropRow instanceof HTMLElement) {
activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
}
activeFileTreeDropRow = null;
}
function resetFileTreeDragState() {
draggingFileTreeRowIds = [];
clearFileTreeDropFeedback();
}
// ─── 文件树 DND 开始 ───────────────────────────────────
function startFileTreeDrag(fileRow, deps) {
deps = deps || {};
var selectedSidebarFileTreeRowIdsForDrag = deps.selectedSidebarFileTreeRowIdsForDrag || function(r) {
return [r.getAttribute('data-row-id') || ''].filter(Boolean);
};
draggingFileTreeRowIds = selectedSidebarFileTreeRowIdsForDrag(fileRow);
return {
payload: filetreeDragPayload(draggingFileTreeRowIds),
effectAllowed: 'copyMove',
};
}
// ─── 文件树 DND 悬浮 ───────────────────────────────────
function handleFileTreeDragOver(event, deps) {
deps = deps || {};
var closestAction = deps.closestAction || function() { return null; };
var fileTree = document.getElementById('sidebar-file-tree-root');
if (!fileTree || !fileTree.contains(event.target)) return false;
var hasFiles = filetreeHasFiles(event.dataTransfer);
var hasInternal = filetreeHasInternalDrag(event.dataTransfer);
if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return false;
event.preventDefault();
clearFileTreeDropFeedback();
activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree;
if (activeFileTreeDropRow instanceof HTMLElement) {
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
}
if (event.dataTransfer) {
var copyModifier = event.altKey || event.ctrlKey || event.metaKey;
event.dataTransfer.dropEffect = hasFiles || copyModifier ? 'copy' : 'move';
}
return true;
}
// ─── 文件树 DND 释放 ───────────────────────────────────
function handleFileTreeDrop(event, deps) {
deps = deps || {};
var closestAction = deps.closestAction || function() { return null; };
var resolveWorkspaceId = deps.resolveWorkspaceId || function() { return ''; };
var fileTreeRowLocalUploadTargetRelativePath = deps.fileTreeRowLocalUploadTargetRelativePath
|| function() { return ''; };
var ensureFileTreeWritableTarget = deps.ensureFileTreeWritableTarget || function() { return Promise.resolve(true); };
var dispatchSidebarEvent = deps.dispatchSidebarEvent || function() {};
var fileTree = document.getElementById('sidebar-file-tree-root');
if (!fileTree || !fileTree.contains(event.target)) return;
var targetRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
var files = event.dataTransfer ? Array.from(event.dataTransfer.files || []) : [];
var raw = event.dataTransfer
? event.dataTransfer.getData(FILETREE_DRAG_MIME)
|| event.dataTransfer.getData('application/x-mnote-file-tree')
|| ''
: '';
var rowIds = draggingFileTreeRowIds.slice();
if (raw) {
rowIds = parseFileTreeDragPayload(raw);
}
if (!files.length && !rowIds.length) return;
event.preventDefault();
var detail = filetreeDropDetail(targetRow, fileTree, {
resolveWorkspaceId: resolveWorkspaceId,
fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath,
});
clearFileTreeDropFeedback();
if (files.length) {
dispatchSidebarEvent('tree.filetree.external-drop', Object.assign({}, detail, { files: files }));
} else {
var copyModifier = event.altKey === true || event.ctrlKey === true || event.metaKey === true;
void Promise.resolve(ensureFileTreeWritableTarget('drop', targetRow, rowIds, copyModifier))
.then(function(writable) {
if (!writable) return;
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, {
rowIds: rowIds,
copy: copyModifier,
}));
});
}
draggingFileTreeRowIds = [];
}
// ─── 导出 ───────────────────────────────────────────────
window.__mnoteFileTreeDndRuntime = {
FILETREE_DRAG_MIME: FILETREE_DRAG_MIME,
draggingFileTreeRowIds: draggingFileTreeRowIds,
activeFileTreeDropRow: activeFileTreeDropRow,
filetreeDragPayload: filetreeDragPayload,
filetreeDropDetail: filetreeDropDetail,
filetreeHasFiles: filetreeHasFiles,
filetreeHasInternalDrag: filetreeHasInternalDrag,
parseFileTreeDragPayload: parseFileTreeDragPayload,
clearFileTreeDropFeedback: clearFileTreeDropFeedback,
resetFileTreeDragState: resetFileTreeDragState,
startFileTreeDrag: startFileTreeDrag,
handleFileTreeDragOver: handleFileTreeDragOver,
handleFileTreeDrop: handleFileTreeDrop,
};
@@ -0,0 +1,92 @@
// MNote filetree keyboard 运行时外置模块。
// 承接 filetree keydown 处理和 clipboard 状态管理。
// inline script 保留完整 fallback。
// ─── Clipboard 状态 ─────────────────────────────────────
var sidebarFileTreeClipboard = null;
function getSidebarFileTreeClipboard() {
return sidebarFileTreeClipboard;
}
function setSidebarFileTreeClipboard(action, rowIds) {
sidebarFileTreeClipboard = { action: action, rowIds: rowIds };
document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', action);
return sidebarFileTreeClipboard;
}
function clearSidebarFileTreeClipboard() {
sidebarFileTreeClipboard = null;
document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action');
}
// ─── Keyboard handler ───────────────────────────────────
function handleFileTreeKeyDown(event, deps) {
deps = deps || {};
var closestAction = deps.closestAction || function() { return null; };
var beginFileTreeInlineRename = deps.beginFileTreeInlineRename || function() {};
var selectedSidebarFileTreeRows = deps.selectedSidebarFileTreeRows || function() { return []; };
var buildSidebarFileTreeContext = deps.buildSidebarFileTreeContext || function() { return {}; };
var evaluateSidebarFileTreeWhen = deps.evaluateSidebarFileTreeWhen || function() { return false; };
var deleteSelectedSidebarFileTreeRows = deps.deleteSelectedSidebarFileTreeRows || function() { return Promise.resolve(); };
var keyTarget = event.target;
var fileTreeRootForKey = document.getElementById('sidebar-file-tree-root');
var fileTreeRowForKey = closestAction(keyTarget, '.tree-row[data-shell-mode="filetree"]');
if (!fileTreeRowForKey && fileTreeRootForKey && fileTreeRootForKey.contains(document.activeElement)) {
fileTreeRowForKey = closestAction(document.activeElement, '.tree-row[data-shell-mode="filetree"]');
}
if (!fileTreeRowForKey) return false;
if (event.key === 'F2') {
event.preventDefault();
beginFileTreeInlineRename(fileTreeRowForKey);
return true;
}
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key) {
var shortcutKey = event.key.toLowerCase();
if (shortcutKey === 'c' || shortcutKey === 'x') {
var selectedRows = selectedSidebarFileTreeRows();
var selectedRowIds = selectedRows.map(function(row) {
return row.getAttribute('data-row-id') || '';
}).filter(Boolean);
if (selectedRowIds.length > 0) {
event.preventDefault();
setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds);
}
return true;
}
if (shortcutKey === 'v') {
event.preventDefault();
if (typeof deps.pasteSidebarFileTreeClipboard === 'function') {
void deps.pasteSidebarFileTreeClipboard(fileTreeRowForKey);
}
return true;
}
}
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
var delCtx = buildSidebarFileTreeContext('filetree');
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')) {
return true;
}
void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey);
return true;
}
return false;
}
// ─── 导出 ───────────────────────────────────────────────
window.__mnoteFileTreeKeyboardRuntime = {
sidebarFileTreeClipboard: sidebarFileTreeClipboard,
getSidebarFileTreeClipboard: getSidebarFileTreeClipboard,
setSidebarFileTreeClipboard: setSidebarFileTreeClipboard,
clearSidebarFileTreeClipboard: clearSidebarFileTreeClipboard,
handleFileTreeKeyDown: handleFileTreeKeyDown,
};
File diff suppressed because it is too large Load Diff
+12
View File
@@ -114,6 +114,18 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/filetree-context-menu-runtime.js",
get(web_shell::filetree_context_menu_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/filetree-dnd-runtime.js",
get(web_shell::filetree_dnd_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/filetree-keyboard-runtime.js",
get(web_shell::filetree_keyboard_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
get(web_shell::sidebar_tree_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/tree-live-controller.js",
get(web_shell::tree_live_controller_runtime_asset),
@@ -2059,6 +2059,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
};
const acceptDiskVersion = async (session) => {
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_RT_ && typeof _RT_.acceptDiskVersion === 'function') {
return _RT_.acceptDiskVersion(session, {
setSessionStatus: setSessionStatus,
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
applyResourceSnapshotToSession: applyResourceSnapshotToSession,
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
applyAggregateSnapshotToSession: applyAggregateSnapshotToSession,
});
}
// inline fallback
setSessionStatus(session, 'conflict-resolving', '...');
if (session.sessionKind === 'resource') {
const latestResource = await fetchLatestResourceSnapshot(session);
@@ -2070,6 +2081,23 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
};
const keepCurrentEditorVersion = async (session) => {
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_RT_ && typeof _RT_.keepCurrentEditorVersion === 'function') {
return _RT_.keepCurrentEditorVersion(session, {
setSessionStatus: setSessionStatus,
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
sessionViews: sessionViews,
currentEditorText: currentEditorText,
normalizePlainText: normalizePlainText,
hydrateMindmapAttrsFromDom: hydrateMindmapAttrsFromDom,
textToTiptapDocument: textToTiptapDocument,
conflictDetectionKeyFromBody: conflictDetectionKeyFromBody,
clearSessionConflictSurface: clearSessionConflictSurface,
persistSession: persistSession,
});
}
// inline fallback
setSessionStatus(session, 'conflict-resolving', '...');
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
@@ -2101,6 +2129,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
};
const writeMergedConflictResult = async (session, panel, mergedText) => {
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_RT_ && typeof _RT_.writeMergedConflictResult === 'function') {
return _RT_.writeMergedConflictResult(session, panel, mergedText, {
setSessionStatus: setSessionStatus,
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
conflictDetectionKeyFromBody: conflictDetectionKeyFromBody,
textToTiptapDocument: textToTiptapDocument,
persistSession: persistSession,
});
}
// inline fallback
setSessionStatus(session, 'conflict-resolving', '...');
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
@@ -4462,6 +4502,48 @@ pub async fn local_upload_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_tree_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-tree-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn filetree_keyboard_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-keyboard-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn filetree_dnd_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-dnd-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn filetree_context_menu_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-context-menu-runtime.js");
Response::builder()
@@ -5677,6 +5759,14 @@ mod tests {
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("runSessionConflictAction: runSessionConflictAction"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function runSessionConflictAction"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function acceptDiskVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function keepCurrentEditorVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function writeMergedConflictResult"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("acceptDiskVersion: acceptDiskVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("keepCurrentEditorVersion: keepCurrentEditorVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("writeMergedConflictResult: writeMergedConflictResult"));
}
#[tokio::test]
File diff suppressed because it is too large Load Diff
@@ -253,19 +253,26 @@ mod tests {
#[test]
fn pointer_corridor_accepts_handle_lane_and_block_edge() {
// All geometry values are stage-relative (substracted stage_left/top).
// handle at absolute x≈108 → stage-rel handle_left≈8
// block starts at absolute x=250 → stage-rel block_left=150
let geometry = HandleCorridorGeometry {
stage_left: 100.0,
stage_top: 20.0,
block_left: 140.0,
block_left: 150.0,
block_top: 40.0,
block_bottom: 80.0,
handle_left: 108.0,
handle_right: 130.0,
block_bottom: 60.0,
handle_left: 8.0,
handle_right: 30.0,
};
// (118,80) → stage-rel (18,60): inside corridor ✓
assert!(pointer_in_handle_corridor_geometry(118, 80, &geometry));
// (250,80) → stage-rel (150,60): inside block-edge corridor ✓
assert!(pointer_in_handle_corridor_geometry(250, 80, &geometry));
// (90,80) → stage-rel (-10,60): x outside corridor (-10 < -4) ✓
assert!(!pointer_in_handle_corridor_geometry(90, 80, &geometry));
// (118,28) → stage-rel (18,8): y above block_top-18 (=22) ✓
assert!(!pointer_in_handle_corridor_geometry(118, 28, &geometry));
}
@@ -0,0 +1,152 @@
//! 编辑器到宿主 shell 的 bridge events 常量与 payload 类型。
//! dispatch 函数保留在 lib.rs 中(与信号/overlay 耦合度高)。
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub(crate) const RUNTIME_NAME: &str = "8123-leptos-tiptap-runtime";
pub(crate) const RUNTIME_VERSION: &str = "1.1.0";
pub(crate) const PROTOCOL: &str = "mnote.leptos_tiptap.bridge.v1";
pub(crate) const BLOCK_DELTA_EVENT: &str = "mnote:editor:block-delta";
pub(crate) const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command";
pub(crate) const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height";
pub(crate) const EVENT_PREFIX: &str = "mnote:leptos-tiptap-spike";
pub(crate) const READY_EVENT: &str = "mnote:leptos-tiptap-spike:ready";
pub(crate) const CHANGE_EVENT: &str = "mnote:leptos-tiptap-spike:change";
pub(crate) const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state";
pub(crate) const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status";
pub(crate) const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection";
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BridgeEnvelope<T>
where
T: Serialize,
{
pub(crate) protocol: &'static str,
pub(crate) runtime: &'static str,
pub(crate) version: &'static str,
pub(crate) source: &'static str,
pub(crate) event: &'static str,
pub(crate) payload: T,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BridgeSelectorsPayload {
pub(crate) root: &'static str,
pub(crate) stage: &'static str,
pub(crate) editor: &'static str,
pub(crate) toolbar: &'static str,
pub(crate) slash_menu: &'static str,
pub(crate) handle: &'static str,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HostStatusPayload {
pub(crate) document_id: Option<String>,
pub(crate) workspace_id: Option<String>,
pub(crate) title: String,
pub(crate) dirty_count: u32,
pub(crate) selected_block_index: Option<usize>,
pub(crate) current_block_id: Option<String>,
pub(crate) editor_focused: bool,
pub(crate) read_only: bool,
pub(crate) slash_open: bool,
pub(crate) toolbar_open: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReadyPayload {
pub(crate) runtime_name: &'static str,
pub(crate) selectors: BridgeSelectorsPayload,
pub(crate) supported_commands: Vec<&'static str>,
pub(crate) supports_embedded_mode: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StatePayload {
pub(crate) document_id: Option<String>,
pub(crate) workspace_id: Option<String>,
pub(crate) title: String,
pub(crate) dirty_count: u32,
pub(crate) selected_block_index: Option<usize>,
pub(crate) editor_focused: bool,
pub(crate) slash_open: bool,
pub(crate) toolbar_open: bool,
pub(crate) read_only: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChangeMetaPayload {
pub(crate) dirty_count: u32,
pub(crate) editor_focused: bool,
pub(crate) slash_open: bool,
pub(crate) toolbar_open: bool,
pub(crate) selected_block_index: Option<usize>,
pub(crate) revision: Option<i64>,
pub(crate) conflict_detection_key: Option<String>,
pub(crate) read_only: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChangePayload {
pub(crate) document_id: Option<String>,
pub(crate) workspace_id: Option<String>,
pub(crate) title: String,
pub(crate) content: Value,
pub(crate) meta: ChangeMetaPayload,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HeightPayload {
pub(crate) height: f64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HostCommandEnvelope {
pub(crate) protocol: Option<String>,
pub(crate) runtime: Option<String>,
pub(crate) version: Option<String>,
pub(crate) source: Option<String>,
pub(crate) event: Option<String>,
pub(crate) payload: Option<HostCommandPayload>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RuntimePageOptions {
pub(crate) wide_layout: Option<bool>,
pub(crate) small_text: Option<bool>,
pub(crate) layout_density: Option<String>,
pub(crate) show_heading_numbers: Option<bool>,
pub(crate) embed_default_block_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HostCommandPayload {
pub(crate) command: Option<String>,
pub(crate) document_id: Option<String>,
pub(crate) workspace_id: Option<String>,
pub(crate) title: Option<String>,
pub(crate) content: Option<Value>,
pub(crate) editable: Option<bool>,
pub(crate) page_options: Option<RuntimePageOptions>,
pub(crate) block_id: Option<String>,
pub(crate) block_index: Option<usize>,
pub(crate) text: Option<String>,
pub(crate) reference_document_id: Option<String>,
pub(crate) reference_block_id: Option<String>,
pub(crate) current_block_id: Option<String>,
pub(crate) selection: Option<Value>,
pub(crate) revision: Option<i64>,
pub(crate) conflict_detection_key: Option<String>,
pub(crate) read_only: Option<bool>,
}
@@ -5,6 +5,7 @@ use serde_json::json;
use serde_json::Value;
use crate::editor_runtime::attachment_links;
use crate::editor_runtime::block_hover_state::DropPlacement;
/// 通过 Tiptap `delete_selection()` 删除当前选中的本地附件链接,保留撤销/重做历史。
///
@@ -111,3 +112,105 @@ pub(crate) fn delete_top_level_block_with_history(
.focus()
.map_err(|err| format!("聚焦编辑器失败:{err}"))
}
/// 通过 Tiptap transaction 移动顶层块,保留撤销/重做历史。
///
/// 先删除源块,再在调整后的目标位置插入。
pub(crate) fn move_top_level_block_with_history(
editor: &TiptapEditorHandle,
source_index: usize,
target_index: usize,
placement: DropPlacement,
) -> Result<(), String> {
// 读取并克隆源块内容
let document = editor
.get_json()
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
let content = document
.get("content")
.and_then(Value::as_array)
.ok_or_else(|| "文档content 数组缺失".to_string())?;
if source_index >= content.len() || target_index >= content.len() {
return Err("移动块索引超出范围".to_string());
}
let source_block = content[source_index].clone();
// 删除源块
delete_top_level_block_with_history(editor, source_index)?;
// 重新计算目标位置(删除后索引可能偏移)
let adjusted_target =
if source_index < target_index {
target_index - 1
} else {
target_index
};
let document = editor
.get_json()
.map_err(|err| format!("重新读取 JSON 失败:{err}"))?;
let range = top_level_block_full_range(&document, adjusted_target)
.ok_or_else(|| format!("无法计算第 {adjusted_target} 个块的插入位置"))?;
let insert_at = match placement {
DropPlacement::Before => range.from,
DropPlacement::After => range.to,
};
editor
.insert_content_at(
TiptapRange {
from: insert_at,
to: insert_at,
},
TiptapContent::json(source_block),
Some(TiptapInsertContentOptions {
update_selection: Some(true),
..Default::default()
}),
)
.map_err(|err| format!("移动块失败:{err}"))?;
editor
.focus()
.map_err(|err| format!("聚焦编辑器失败:{err}"))
}
/// 通过 Tiptap transaction 复制顶层块并插入其后,保留撤销/重做历史。
pub(crate) fn duplicate_top_level_block_with_history(
editor: &TiptapEditorHandle,
source_index: usize,
) -> Result<(), String> {
let document = editor
.get_json()
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
let content = document
.get("content")
.and_then(Value::as_array)
.ok_or_else(|| "文档content 数组缺失".to_string())?;
if source_index >= content.len() {
return Err("复制块索引超出范围".to_string());
}
let source_block = content[source_index].clone();
let range = top_level_block_full_range(&document, source_index)
.ok_or_else(|| format!("无法计算第 {source_index} 个块的位置"))?;
// 插入到源块之后
editor
.insert_content_at(
TiptapRange {
from: range.to,
to: range.to,
},
TiptapContent::json(source_block),
Some(TiptapInsertContentOptions {
update_selection: Some(true),
..Default::default()
}),
)
.map_err(|err| format!("复制块失败:{err}"))?;
editor
.focus()
.map_err(|err| format!("聚焦编辑器失败:{err}"))
}
@@ -0,0 +1,904 @@
//! mindmap shell 的 node view / runtime mount / event bridge。
//!
//! 本模块承接 mindmap block 的完整 NodeView 实现:
//! - 类型定义(MindmapShellOptions 等)
//! - Rust Leptos shell 组件(MindmapShell
//! - 工具栏、侧栏、导航栏子组件
//! - simplemindmap event bridgeCustomEvent 派发)
//! - mount / unmount 注册表
//!
//! #[wasm_bindgen] 薄包装器保留在 lib.rs 中。
use leptos::prelude::*;
use leptos::ev;
use leptos::mount::{mount_to, UnmountHandle};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_wasm_bindgen;
use std::any::Any;
use std::collections::HashMap;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{CustomEvent, CustomEventInit, EventTarget, HtmlElement, HtmlInputElement};
use crate::next_mount_id;
thread_local! {
static MOUNTED_MINDMAP_SHELLS: std::cell::RefCell<HashMap<u32, MountedMindmapShell>> = std::cell::RefCell::new(HashMap::new());
}
pub(crate) const MINDMAP_SHELL_ACTION_EVENT: &str = "mnote:mindmap-shell:action";
pub(crate) const MINDMAP_SHELL_PANEL_EVENT: &str = "mnote:mindmap-shell:panel";
pub(crate) const MINDMAP_SHELL_MINIMAP_EVENT: &str = "mnote:mindmap-shell:minimap";
pub(crate) const MINDMAP_SHELL_ZOOM_EVENT: &str = "mnote:mindmap-shell:zoom";
pub(crate) const MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT: &str = "mnote:mindmap-shell:toolbar-overflow";
pub(crate) struct MountedMindmapShell {
mount_handle: Box<dyn Any>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellAction {
id: String,
label: String,
#[serde(default)]
long_label: Option<String>,
icon: String,
#[serde(default)]
icon_key: Option<String>,
#[serde(default)]
priority: Option<u32>,
#[serde(default)]
overflow_group: Option<String>,
#[serde(default)]
cluster: Option<String>,
disabled: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellToolbarGroup {
id: String,
label: String,
actions: Vec<MindmapShellAction>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellSidebarPanel {
id: String,
kind: String,
label: String,
icon: String,
active: bool,
body_title: String,
body_caption: String,
#[serde(default)]
options: Vec<MindmapShellSidebarOption>,
#[serde(default)]
body_items: Vec<String>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellSidebarOption {
id: String,
label: String,
action_id: Option<String>,
value: Value,
control_type: String,
#[serde(default)]
preview: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
readonly: bool,
compat_path: Option<String>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellNavigator {
word_count: u32,
node_count: u32,
zoom_percent: u32,
readonly: bool,
#[serde(default)]
minimap_open: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellToolbarOverflowState {
available_width: Option<f64>,
#[serde(default)]
visible_action_ids: Vec<String>,
#[serde(default)]
overflow_action_ids: Vec<String>,
#[serde(default)]
more_open: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellFullscreenState {
mode: String,
is_fullscreen: bool,
target: String,
#[serde(default = "default_true")]
api_available: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellSidebarState {
trigger_visible: bool,
panel_open: bool,
active_panel_id: Option<String>,
drawer_width: u32,
collapsed_by_toggle: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellNavigatorState {
search_open: bool,
minimap_open: bool,
readonly: bool,
zoom_percent: u32,
mouse_behavior: String,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellInteractionState {
chrome_visibility: String,
toolbar_overflow: MindmapShellToolbarOverflowState,
fullscreen: MindmapShellFullscreenState,
sidebar: MindmapShellSidebarState,
navigator: MindmapShellNavigatorState,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellOptions {
mindmap_id: String,
toolbar_groups: Vec<MindmapShellToolbarGroup>,
sidebar_panels: Vec<MindmapShellSidebarPanel>,
navigator: MindmapShellNavigator,
shell: MindmapShellInteractionState,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellActionPayload<'a> {
action_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<&'a Value>,
#[serde(skip_serializing_if = "Option::is_none")]
compat_path: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<&'a str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellPanelPayload<'a> {
event: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
panel_id: Option<&'a str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellMinimapPayload {
open: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellZoomPayload {
percent: u32,
}
fn default_true() -> bool {
true
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MindmapShellToolbarOverflowPayload {
more_open: bool,
}
fn dispatch_mindmap_shell_event<T>(target: &EventTarget, event_name: &'static str, payload: &T)
where
T: Serialize,
{
let Ok(detail_value) = serde_wasm_bindgen::to_value(payload) else {
return;
};
let init = CustomEventInit::new();
init.set_detail(&detail_value);
init.set_bubbles(true);
if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) {
let _ = target.dispatch_event(&event);
}
}
fn render_mindmap_toolbar_action(
action: MindmapShellAction,
class_name: &'static str,
source: &'static str,
action_target: EventTarget,
) -> impl IntoView {
let action_id = action.id.clone();
let action_label_title = action
.long_label
.clone()
.filter(|label| !label.is_empty())
.unwrap_or_else(|| action.label.clone());
let action_label_aria = action_label_title.clone();
let action_label_text = action.label.clone();
let action_icon_text = action.icon.clone();
let action_icon_key = action.icon_key.clone().unwrap_or_default();
let action_overflow_group = action.overflow_group.clone().unwrap_or_default();
let action_cluster = action.cluster.clone().unwrap_or_default();
let action_priority = action.priority.unwrap_or(u32::MAX).to_string();
let action_disabled = action.disabled;
view! {
<button
type="button"
class=class_name
data-testid=format!("mindmap-schema-toolbar-action-{}", action_id)
data-mindmap-action-id=action.id.clone()
data-icon-key=action_icon_key
data-overflow-group=action_overflow_group
data-toolbar-cluster=action_cluster
data-priority=action_priority
title=action_label_title
aria-label=action_label_aria
disabled=action_disabled
on:click=move |_| {
dispatch_mindmap_shell_event(
&action_target,
MINDMAP_SHELL_ACTION_EVENT,
&MindmapShellActionPayload {
action_id: &action_id,
value: None,
compat_path: None,
source: Some(source),
},
);
}
>
<span class="mnote-mindmap-tool-icon" aria-hidden="true">{action_icon_text}</span>
<span class="mnote-mindmap-tool-label">{action_label_text}</span>
</button>
}
}
fn render_mindmap_sidebar_option(
option: MindmapShellSidebarOption,
action_target: EventTarget,
) -> impl IntoView {
let option_id = option.id.clone();
let option_label = option.label.clone();
let option_control_type = option.control_type.clone();
let action_id = option.action_id.clone();
let option_value = option.value.clone();
let compat_path = option.compat_path.clone();
let option_preview = option.preview.clone().unwrap_or_default();
let option_description = option.description.clone().unwrap_or_default();
let option_readonly = option.readonly;
let option_action_id_attr = action_id.clone().unwrap_or_default();
if option_readonly || action_id.is_none() {
return view! {
<div
class="mnote-mindmap-side-option mnote-mindmap-side-option-readonly"
data-testid=format!("mindmap-schema-sidebar-option-{}", option_id)
data-mindmap-sidebar-option-id=option.id
data-control-type=option_control_type.clone()
data-readonly="true"
>
<span class="mnote-mindmap-side-option-label">{option_label}</span>
{if option_description.is_empty() {
().into_any()
} else {
view! { <span class="mnote-mindmap-side-option-description">{option_description}</span> }.into_any()
}}
</div>
}
.into_any();
}
let button_class = if option_control_type == "layoutCard" {
"mnote-mindmap-side-option mnote-mindmap-side-option-layout-card"
} else if option_control_type == "swatch" {
"mnote-mindmap-side-option mnote-mindmap-side-option-swatch"
} else if option_control_type == "treeItem" {
"mnote-mindmap-side-option mnote-mindmap-side-option-tree-item"
} else {
"mnote-mindmap-side-option"
};
view! {
<button
type="button"
class=button_class
data-testid=format!("mindmap-schema-sidebar-option-{}", option_id)
data-mindmap-sidebar-option-id=option.id
data-mindmap-action-id=option_action_id_attr
data-control-type=option_control_type.clone()
on:click=move |_| {
if let Some(action_id) = action_id.as_deref() {
dispatch_mindmap_shell_event(
&action_target,
MINDMAP_SHELL_ACTION_EVENT,
&MindmapShellActionPayload {
action_id,
value: Some(&option_value),
compat_path: compat_path.as_deref(),
source: Some("sidebar"),
},
);
}
}
>
{if option_control_type == "swatch" {
view! {
<span class="mnote-mindmap-side-option-swatch-chip" style=format!("background:{};", option_preview.clone())></span>
}
.into_any()
} else if option_control_type == "layoutCard" {
view! {
<span class="mnote-mindmap-side-option-preview-card">{option_preview.clone()}</span>
}
.into_any()
} else {
().into_any()
}}
<span class="mnote-mindmap-side-option-label">{option_label}</span>
{if option_description.is_empty() {
().into_any()
} else {
view! { <span class="mnote-mindmap-side-option-description">{option_description}</span> }.into_any()
}}
</button>
}
.into_any()
}
#[component]
pub(crate) fn MindmapShell(options: MindmapShellOptions, event_target: EventTarget) -> impl IntoView {
let toolbar_groups = options.toolbar_groups.clone();
let sidebar_panels = options.sidebar_panels.clone();
let navigator = options.navigator.clone();
let shell = options.shell.clone();
let minimap_open = shell.navigator.minimap_open;
let search_open = shell.navigator.search_open;
let sidebar_panel_open = shell.sidebar.panel_open;
let sidebar_trigger_visible = shell.sidebar.trigger_visible;
let active_panel_id = shell.sidebar.active_panel_id.clone();
let sidebar_drawer_width = shell.sidebar.drawer_width.to_string();
let chrome_visibility = shell.chrome_visibility.clone();
let fullscreen_mode = shell.fullscreen.mode.clone();
let fullscreen_target = shell.fullscreen.target.clone();
let navigator_mouse_behavior = shell.navigator.mouse_behavior.clone();
let toolbar_available_width = shell
.toolbar_overflow
.available_width
.map(|width| width.round().to_string())
.unwrap_or_else(|| "".to_string());
let chrome_visible = shell.chrome_visibility == "visible";
let toolbar_visible_actions = shell.toolbar_overflow.visible_action_ids.join(",");
let toolbar_overflow_actions = shell.toolbar_overflow.overflow_action_ids.join(",");
let sidebar_drawer_width_value = shell.sidebar.drawer_width;
let navigator_right_offset = if chrome_visible && sidebar_panel_open && sidebar_trigger_visible
{
sidebar_drawer_width_value + 112
} else if chrome_visible && sidebar_trigger_visible {
108
} else {
28
};
let navigator_right_style = format!("right: {}px;", navigator_right_offset);
let minimap_right_style = format!("right: {}px;", navigator_right_offset);
let mut primary_toolbar_actions: Vec<MindmapShellAction> = Vec::new();
let mut overflow_toolbar_actions: Vec<MindmapShellAction> = Vec::new();
let mut file_toolbar_actions: Vec<MindmapShellAction> = Vec::new();
for group in toolbar_groups.into_iter() {
match group.id.as_str() {
"overflow" => overflow_toolbar_actions.extend(group.actions),
"file" => file_toolbar_actions.extend(group.actions),
_ => primary_toolbar_actions.extend(group.actions),
}
}
primary_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX));
overflow_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX));
file_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX));
let has_toolbar_overflow = !overflow_toolbar_actions.is_empty();
let toolbar_more_open = shell.toolbar_overflow.more_open && has_toolbar_overflow;
let action_target = event_target.clone();
let panel_target = event_target;
view! {
<div
class="mnote-mindmap-rust-shell"
data-testid="mindmap-rust-shell"
data-ui-shell-source="leptos-rust-shell"
data-mnote-mindmap-id=options.mindmap_id
data-chrome-visibility=chrome_visibility
data-toolbar-available-width=toolbar_available_width
data-toolbar-visible-actions=toolbar_visible_actions
data-toolbar-overflow-actions=toolbar_overflow_actions
data-toolbar-more-open=toolbar_more_open.to_string()
data-fullscreen-mode=fullscreen_mode
data-fullscreen-active=shell.fullscreen.is_fullscreen.to_string()
data-fullscreen-target=fullscreen_target
data-fullscreen-api-available=shell.fullscreen.api_available.to_string()
data-sidebar-trigger-visible=sidebar_trigger_visible.to_string()
data-sidebar-panel-open=sidebar_panel_open.to_string()
data-sidebar-active-panel=active_panel_id.unwrap_or_default()
data-sidebar-drawer-width=sidebar_drawer_width
data-sidebar-collapsed-by-toggle=shell.sidebar.collapsed_by_toggle.to_string()
data-navigator-search-open=shell.navigator.search_open.to_string()
data-navigator-minimap-open=shell.navigator.minimap_open.to_string()
data-navigator-readonly=shell.navigator.readonly.to_string()
data-navigator-zoom-percent=shell.navigator.zoom_percent.to_string()
data-navigator-mouse-behavior=navigator_mouse_behavior
>
{if chrome_visible {
view! { <div
class="mnote-mindmap-command-toolbar mnote-mindmap-schema-toolbar"
data-testid="mindmap-schema-toolbar"
data-ui-source="leptos-rust-shell"
data-schema-source="mindmapDefaultUiSchema"
>
<div class="mnote-mindmap-toolbar-primary" data-testid="mindmap-schema-toolbar-groups">
<div class="mnote-mindmap-toolbar-cluster mnote-mindmap-toolbar-cluster-main" data-testid="mindmap-schema-toolbar-cluster-main">
{primary_toolbar_actions
.into_iter()
.map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone()))
.collect_view()}
</div>
{if has_toolbar_overflow {
let overflow_toggle_target = action_target.clone();
let overflow_menu_actions = overflow_toolbar_actions.clone();
view! {
<div class="mnote-mindmap-toolbar-more" data-testid="mindmap-schema-toolbar-more">
<button
type="button"
class="mnote-mindmap-tool mnote-mindmap-toolbar-more-button"
data-testid="mindmap-schema-toolbar-action-more"
data-mindmap-action-id="more"
data-open=toolbar_more_open.to_string()
aria-haspopup="menu"
aria-expanded=toolbar_more_open.to_string()
title="更多"
aria-label="更多"
on:click=move |_| {
dispatch_mindmap_shell_event(
&overflow_toggle_target,
MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT,
&MindmapShellToolbarOverflowPayload { more_open: !toolbar_more_open },
);
}
>
<span class="mnote-mindmap-tool-icon" aria-hidden="true">""</span>
<span class="mnote-mindmap-tool-label">"更多"</span>
</button>
{if toolbar_more_open {
view! {
<div class="mnote-mindmap-toolbar-more-menu" data-testid="mindmap-schema-toolbar-more-menu" role="menu">
{overflow_menu_actions
.into_iter()
.map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool mnote-mindmap-toolbar-more-item", "toolbar", action_target.clone()))
.collect_view()}
</div>
}.into_any()
} else {
().into_any()
}}
</div>
}.into_any()
} else {
().into_any()
}}
<div class="mnote-mindmap-toolbar-cluster mnote-mindmap-toolbar-cluster-file" data-testid="mindmap-schema-toolbar-cluster-file">
{file_toolbar_actions
.into_iter()
.map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone()))
.collect_view()}
</div>
</div>
</div> }.into_any()
} else {
().into_any()
}}
{if chrome_visible {
view! { <aside
class="mnote-mindmap-side-panel mnote-mindmap-schema-sidebar"
data-testid="mindmap-schema-sidebar"
data-ui-source="leptos-rust-shell"
data-schema-source="mindmapDefaultUiSchema"
data-trigger-visible=sidebar_trigger_visible.to_string()
data-panel-open=sidebar_panel_open.to_string()
data-collapsed-by-toggle=shell.sidebar.collapsed_by_toggle.to_string()
>
{if sidebar_trigger_visible {
view! {
<div class="mnote-mindmap-side-rail" data-testid="mindmap-schema-sidebar-rail">
<button
type="button"
class="mnote-mindmap-side-rail-handle"
data-testid="mindmap-schema-sidebar-hide-handle"
aria-label="隐藏侧栏"
title="隐藏侧栏"
on:click={
let panel_target = panel_target.clone();
move |_| {
dispatch_mindmap_shell_event(
&panel_target,
MINDMAP_SHELL_PANEL_EVENT,
&MindmapShellPanelPayload {
event: "hideTrigger",
panel_id: None,
},
);
}
}
>
""
</button>
{sidebar_panels
.iter()
.cloned()
.map(|panel| {
let panel_id = panel.id.clone();
let panel_kind = panel.kind.clone();
let panel_target = panel_target.clone();
view! {
<button
type="button"
class="mnote-mindmap-side-tab"
data-testid=format!("mindmap-schema-sidebar-tab-{}", panel_id)
data-mindmap-sidebar-panel-id=panel.id.clone()
data-sidebar-kind=panel_kind
data-active=panel.active.to_string()
on:click=move |_| {
dispatch_mindmap_shell_event(
&panel_target,
MINDMAP_SHELL_PANEL_EVENT,
&MindmapShellPanelPayload {
event: "togglePanel",
panel_id: Some(&panel_id),
},
);
}
>
<span class="mnote-mindmap-side-icon">{panel.icon}</span>
<span class="mnote-mindmap-side-tab-label">{panel.label}</span>
</button>
}
})
.collect_view()}
</div>
}
.into_any()
} else {
view! {
<button
type="button"
class="mnote-mindmap-side-restore-handle"
data-testid="mindmap-schema-sidebar-restore-handle"
aria-label="恢复侧栏"
title="恢复侧栏"
on:click={
let panel_target = panel_target.clone();
move |_| {
dispatch_mindmap_shell_event(
&panel_target,
MINDMAP_SHELL_PANEL_EVENT,
&MindmapShellPanelPayload {
event: "restoreTrigger",
panel_id: None,
},
);
}
}
>
""
</button>
}
.into_any()
}}
{if sidebar_panel_open {
sidebar_panels
.iter()
.find(|panel| panel.active)
.or_else(|| sidebar_panels.first())
.cloned()
.map(|panel| view! {
<div
class="mnote-mindmap-side-body mnote-mindmap-side-drawer"
data-testid="mindmap-schema-sidebar-drawer"
data-sidebar-panel-id=panel.id.clone()
data-sidebar-kind=panel.kind.clone()
style=format!("width: {}px;", sidebar_drawer_width_value)
>
<div class="mnote-mindmap-side-drawer-header">
<div class="mnote-mindmap-side-drawer-heading">
<strong data-testid="mindmap-schema-sidebar-title">{panel.body_title}</strong>
<span>{panel.body_caption}</span>
</div>
<button
type="button"
class="mnote-mindmap-side-drawer-close"
data-testid="mindmap-schema-sidebar-close"
aria-label="关闭侧栏"
title="关闭侧栏"
on:click={
let panel_target = panel_target.clone();
move |_| {
dispatch_mindmap_shell_event(
&panel_target,
MINDMAP_SHELL_PANEL_EVENT,
&MindmapShellPanelPayload {
event: "closeDrawer",
panel_id: None,
},
);
}
}
>
"×"
</button>
</div>
<div class="mnote-mindmap-side-options" data-testid=format!("mindmap-schema-sidebar-options-{}", panel.id)>
{panel.options
.into_iter()
.map(|option| render_mindmap_sidebar_option(option, action_target.clone()))
.collect_view()}
</div>
<ol class="mnote-mindmap-side-outline" data-testid=format!("mindmap-schema-sidebar-outline-{}", panel.id)>
{panel.body_items
.into_iter()
.map(|item| view! { <li>{item}</li> })
.collect_view()}
</ol>
</div>
}.into_any())
.unwrap_or_else(|| ().into_any())
} else {
().into_any()
}}
</aside> }.into_any()
} else {
().into_any()
}}
<div
class="mnote-mindmap-count"
data-testid="mindmap-schema-count"
data-ui-source="leptos-rust-shell"
data-schema-source="mindmapDefaultUiSchema"
>
<span data-testid="mindmap-schema-navigator-stats">{format!("字数 {}", navigator.word_count)}</span>
<span>{format!("节点 {}", navigator.node_count)}</span>
</div>
{if chrome_visible {
view! { <div
class="mnote-mindmap-bottom-bar mnote-mindmap-schema-navigator"
data-testid="mindmap-schema-navigator"
data-ui-source="leptos-rust-shell"
data-schema-source="mindmapDefaultUiSchema"
style=navigator_right_style
>
<div class="mnote-mindmap-schema-navigator-group">
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-centerRoot" data-mindmap-action-id="centerRoot" title="回根节点" aria-label="回根节点" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "centerRoot",
value: None,
compat_path: None,
source: None,
})
}><span class="material-symbols-outlined" data-icon="home" aria-hidden="true"></span></button>
<div class="mnote-mindmap-schema-navigator-search-wrap">
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-search" data-mindmap-action-id="search" data-active=search_open.to_string() title="搜索" aria-label="搜索" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "search",
value: None,
compat_path: None,
source: None,
})
}><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span></button>
{if search_open {
view! {
<input
data-testid="mindmap-schema-navigator-search"
data-mindmap-action-id="search"
placeholder="搜索"
aria-label="搜索"
on:input={
let action_target = action_target.clone();
move |event| {
let value = event_target_value(&event);
let value = json!(value);
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "search",
value: Some(&value),
compat_path: None,
source: None,
})
}
}
on:keydown={
let action_target = action_target.clone();
move |event: ev::KeyboardEvent| {
if event.key() == "Escape" {
let value = json!(false);
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "search",
value: Some(&value),
compat_path: None,
source: None,
});
}
}
}
/>
}.into_any()
} else {
().into_any()
}}
</div>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-minimap" data-active=minimap_open.to_string() title="小地图" aria-label="小地图" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_MINIMAP_EVENT, &MindmapShellMinimapPayload { open: !minimap_open })
}><span class="material-symbols-outlined" data-icon="account_tree" aria-hidden="true"></span></button>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-settings" data-active=(sidebar_panel_open && sidebar_trigger_visible).to_string() title="设置" aria-label="设置" on:click={
let action_target = action_target.clone();
let active_panel_id = active_panel_id.clone();
move |_| {
let event = if sidebar_trigger_visible { "togglePanel" } else { "restoreTrigger" };
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_PANEL_EVENT, &MindmapShellPanelPayload {
event,
panel_id: active_panel_id.as_deref(),
})
}
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">""</span></button>
</div>
<div class="mnote-mindmap-schema-navigator-group">
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-zoomOut" data-mindmap-action-id="zoomOut" title="缩小" aria-label="缩小" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "zoomOut",
value: None,
compat_path: None,
source: None,
})
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">""</span></button>
<input
class="mnote-mindmap-schema-navigator-zoom"
data-testid="mindmap-schema-navigator-zoom-input"
value=format!("{}%", navigator.zoom_percent)
aria-label="缩放百分比"
on:change={
let action_target = action_target.clone();
move |event| {
let raw = event_target_value(&event);
let normalized = raw.replace('%', "").trim().to_string();
if let Ok(percent) = normalized.parse::<u32>() {
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ZOOM_EVENT, &MindmapShellZoomPayload { percent });
} else if let Some(target) = event.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
input.set_value(&format!("{}%", navigator.zoom_percent));
}
}
}
}
on:keydown=move |event: ev::KeyboardEvent| {
if event.key() == "Escape" {
if let Some(target) = event.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
input.set_value(&format!("{}%", navigator.zoom_percent));
input.blur().ok();
}
}
}
}
/>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-zoomIn" data-mindmap-action-id="zoomIn" title="放大" aria-label="放大" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "zoomIn",
value: None,
compat_path: None,
source: None,
})
}><span class="material-symbols-outlined" data-icon="add" aria-hidden="true"></span></button>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-fullscreenCanvas" data-mindmap-action-id="fullscreenCanvas" data-active=shell.fullscreen.is_fullscreen.to_string() data-disabled-reason=if shell.fullscreen.api_available { "" } else { "fullscreen-unavailable" } disabled=!shell.fullscreen.api_available title=if shell.fullscreen.is_fullscreen { "退出全屏" } else { "全屏查看" } aria-label=if shell.fullscreen.is_fullscreen { "退出全屏" } else { "全屏查看" } on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: if shell.fullscreen.is_fullscreen { "exitFullscreen" } else { "fullscreenCanvas" },
value: None,
compat_path: None,
source: None,
})
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">{if shell.fullscreen.is_fullscreen { "" } else { "" }}</span></button>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-readonly" data-mindmap-action-id="readonly" data-active=navigator.readonly.to_string() title=if navigator.readonly { "切回编辑" } else { "切换只读" } aria-label=if navigator.readonly { "切回编辑" } else { "切换只读" } on:click={
let action_target = action_target;
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "readonly",
value: None,
compat_path: None,
source: None,
})
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">{if navigator.readonly { "" } else { "" }}</span></button>
</div>
</div> }.into_any()
} else {
().into_any()
}}
{if minimap_open {
view! {
<div
class="mnote-mindmap-minimap"
data-testid="mindmap-schema-minimap"
data-ui-source="leptos-rust-shell"
style=minimap_right_style
>
<div class="mnote-mindmap-minimap-map">
<span></span>
<span></span>
<span></span>
</div>
<div class="mnote-mindmap-minimap-viewport" data-testid="mindmap-schema-minimap-viewport"></div>
</div>
}.into_any()
} else {
().into_any()
}}
</div>
}
}
fn take_mindmap_shell_handle(id: u32) -> Option<MountedMindmapShell> {
MOUNTED_MINDMAP_SHELLS.with(|registry| registry.borrow_mut().remove(&id))
}
pub(crate) fn mount_mindmap_shell_impl(container: JsValue, options: JsValue) -> Result<u32, JsValue> {
console_error_panic_hook::set_once();
let options = serde_wasm_bindgen::from_value::<MindmapShellOptions>(options)?;
let target = container
.dyn_into::<HtmlElement>()
.map_err(|_| JsValue::from_str("mindmap shell mount 目标必须是 HTML 元素"))?;
let mount_id = next_mount_id();
let event_target: EventTarget = target.clone().into();
let handle = mount_to(target, move || {
view! {
<MindmapShell options=options event_target=event_target />
}
});
MOUNTED_MINDMAP_SHELLS.with(|registry| {
registry.borrow_mut().insert(
mount_id,
MountedMindmapShell {
mount_handle: Box::new(handle),
},
);
});
Ok(mount_id)
}
pub(crate) fn unmount_mindmap_shell_impl(mount_id: u32) -> Result<(), JsValue> {
if take_mindmap_shell_handle(mount_id).is_some() {
Ok(())
} else {
Err(JsValue::from_str("找不到对应的 mindmap shell 挂载句柄"))
}
}
@@ -292,6 +292,23 @@ pub(crate) fn toolbar_overlay_locked(
turn_into_open || color_menu_open || more_menu_open
}
/// 判断是否应自动关闭 toolbar overlay。
/// 当编辑器失焦、无选区且无锁定 toolbar,或 slash/block menu 打开时返回 true。
pub(crate) fn should_auto_close_toolbar_overlays(
editor_focused: bool,
text_selection_active: bool,
turn_into_open: bool,
color_menu_open: bool,
more_menu_open: bool,
slash_open: bool,
block_menu_open: bool,
) -> bool {
let toolbar_locked = toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open);
((!editor_focused || !text_selection_active) && !toolbar_locked)
|| slash_open
|| block_menu_open
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn sync_overlays_on_selection_change(
turn_into_open: ReadSignal<bool>,
+4 -4
View File
@@ -3389,10 +3389,10 @@ mod tests {
#[test]
fn slash_menu_css_uses_viewport_overlay_layer() {
assert!(STYLE.contains(".slash-menu"));
assert!(STYLE.contains("position: fixed;"));
assert!(STYLE.contains("z-index: 130;"));
assert!(!STYLE.contains("z-index: 11;"));
assert!(SPIKE_STYLE.contains(".slash-menu"));
assert!(SPIKE_STYLE.contains("position: fixed;"));
assert!(SPIKE_STYLE.contains("z-index: 130;"));
assert!(!SPIKE_STYLE.contains("z-index: 11;"));
}
}