feat: sidebar runtime extraction + history-safe commands + checklist updates
This commit is contained in:
@@ -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-25:Codex 主控继续收口 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-25(Reasonix 第一批):冲突业务逻辑收口。
|
||||
- `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-25(Reasonix 第二批):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-25(Reasonix 第三批):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 行 bootstrap(flag 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 RED(tree 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 | 主 runtime(SIDEBAR_TREE_JS 同构迁出) |
|
||||
| `filetree-runtime.js` | 607 | 文件树数据 helper(43 符号) |
|
||||
| `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 A:SIDEBAR_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` 常量用于内联降级路径
|
||||
- 验证:启动服务器 + 浏览器 smoke(task479、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 B:Editor 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-25:Reasonix 基于 3-19/5-28 原始设计制定本完整收口计划。
|
||||
- 2026-05-25:Batch 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-25:Batch 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 均 PASS(result.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-25(Reasonix 第一批):修复测试阻断 + 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-25(Reasonix 第二批):`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-25(Reasonix 第三批):`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-25(Reasonix 第四批):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-25:Reasonix 独立收口完成。更改本 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
@@ -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 bridge(CustomEvent 派发)
|
||||
//! - 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>,
|
||||
|
||||
@@ -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;"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user