From d84da5583bb69ae999495860ede99ed0999bb02f Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Mon, 25 May 2026 17:36:17 +0800 Subject: [PATCH] feat: sidebar runtime extraction + history-safe commands + checklist updates --- AGENTS.md | 4 +- ...0-browser-runtime-followup-checklist-v1.md | 77 +- ...-21-final-sidebar-runtime-extraction-v1.md | 199 + ...29-editor-runtime-followup-checklist-v1.md | 84 +- .../document-conflict-panel-runtime.js | 145 +- .../mnote-web/browser/filetree-dnd-runtime.js | 174 + .../browser/filetree-keyboard-runtime.js | 92 + .../mnote-web/browser/sidebar-tree-runtime.js | 10654 ++++++++++++++ rust/crates/mnote-web/src/routes/mod.rs | 12 + rust/crates/mnote-web/src/routes/web_shell.rs | 90 + rust/crates/mnote-web/src/ssr/pages/layout.rs | 11486 +--------------- .../src/editor_runtime/block_dnd.rs | 15 +- .../src/editor_runtime/bridge_events.rs | 152 + .../editor_runtime/history_safe_commands.rs | 103 + .../src/editor_runtime/mindmap_node_view.rs | 904 ++ .../src/editor_runtime/overlays.rs | 17 + rust/spikes/leptos-tiptap-spike/src/lib.rs | 8 +- 17 files changed, 13166 insertions(+), 11050 deletions(-) create mode 100644 design/03-rust-web/process/3-21-final-sidebar-runtime-extraction-v1.md create mode 100644 rust/crates/mnote-web/browser/filetree-dnd-runtime.js create mode 100644 rust/crates/mnote-web/browser/filetree-keyboard-runtime.js create mode 100644 rust/crates/mnote-web/browser/sidebar-tree-runtime.js create mode 100644 rust/spikes/leptos-tiptap-spike/src/editor_runtime/bridge_events.rs create mode 100644 rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs diff --git a/AGENTS.md b/AGENTS.md index c93abb6b..ab7ee329 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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。 diff --git a/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md b/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md index db9224b1..de3df59a 100644 --- a/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md +++ b/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md @@ -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),实际运行时通过 ` + + + @@ -10775,9 +197,12 @@ pub fn PageLayout( #[cfg(test)] mod tests { - use super::SIDEBAR_TREE_JS; + const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-tree-runtime.js"); const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str = include_str!("../../../browser/filetree-context-menu-runtime.js"); + const FILETREE_DND_RUNTIME_JS: &str = include_str!("../../../browser/filetree-dnd-runtime.js"); + const FILETREE_KEYBOARD_RUNTIME_JS: &str = + include_str!("../../../browser/filetree-keyboard-runtime.js"); const FILETREE_RUNTIME_JS: &str = include_str!("../../../browser/filetree-runtime.js"); const FILETREE_SELECTION_RUNTIME_JS: &str = include_str!("../../../browser/filetree-selection-runtime.js"); @@ -10806,93 +231,95 @@ mod tests { #[test] fn sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions() { - assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-navigation-pending")); - assert!(SIDEBAR_TREE_JS.contains("MNOTE_SIDEBAR_TREE_MODE_KEY")); - assert!(SIDEBAR_TREE_JS.contains("MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY")); - assert!(SIDEBAR_TREE_JS.contains("MNOTE_LAST_CLOUD_WORKSPACE_KEY")); - assert!(SIDEBAR_TREE_JS.contains("data-global-option-checkbox=\"showHeadingNumbers\"")); - assert!(SIDEBAR_TREE_JS.contains("restoreSidebarTreeTab")); - assert!(SIDEBAR_TREE_JS.contains("treeView")); - assert!(SIDEBAR_TREE_JS.contains("tree:local-command")); - assert!(SIDEBAR_TREE_JS.contains("applyCreatedDocumentLocally")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'create")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'remove")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'rename")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'move")); - assert!(SIDEBAR_TREE_JS.contains("function sortOrderFromDelta(data)")); - assert!(SIDEBAR_TREE_JS.contains("data.sortOrder ?? data.sort_order")); - assert!(SIDEBAR_TREE_JS.contains("function insertTreeNodeAtSortOrder")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnoteNavigationInFlight")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-navigation-pending")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_SIDEBAR_TREE_MODE_KEY")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_LAST_CLOUD_WORKSPACE_KEY")); + assert!( + SIDEBAR_TREE_RUNTIME_JS.contains("data-global-option-checkbox=\"showHeadingNumbers\"") + ); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("treeView")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree:local-command")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("applyCreatedDocumentLocally")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-tree-local-command-applied', 'create")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-tree-local-command-applied', 'remove")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-tree-local-command-applied', 'rename")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-tree-local-command-applied', 'move")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function sortOrderFromDelta(data)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data.sortOrder ?? data.sort_order")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function insertTreeNodeAtSortOrder")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })")); - assert!(SIDEBAR_TREE_JS.contains("wolai:assets-changed")); - assert!(SIDEBAR_TREE_JS.contains("applyAssetsChangedToFileTree")); - assert!(SIDEBAR_TREE_JS.contains("installMindmapAssetFetchObserver")); - assert!(SIDEBAR_TREE_JS.contains("parseMindmapApiTarget")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-assets-local-applied")); - assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node")); - assert!(SIDEBAR_TREE_JS.contains("dragstart")); - assert!(SIDEBAR_TREE_JS.contains("drop")); - assert!(SIDEBAR_TREE_JS.contains("data-drop-feedback")); - assert!(SIDEBAR_TREE_JS.contains("action: 'move'")); - assert!(SIDEBAR_TREE_JS.contains("sidebar-file-tree-root")); - assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open")); - assert!(SIDEBAR_TREE_JS.contains("tree.asset.open")); - assert!(SIDEBAR_TREE_JS.contains("openConvexAssetFromFileTree")); - assert!(SIDEBAR_TREE_JS.contains("buildMindmapOpenPath")); - assert!(SIDEBAR_TREE_JS.contains("isMindmapAssetDetail")); - assert!(SIDEBAR_TREE_JS.contains("^思维导图")); - assert!(!SIDEBAR_TREE_JS.contains("openMindmapAssetInDocumentShell")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-mindmap-asset-open-mode")); - assert!(SIDEBAR_TREE_JS.contains("mindmap-object-shell")); - assert!(SIDEBAR_TREE_JS.contains("navigateToMindmapObject(documentId, assetId")); - assert!(SIDEBAR_TREE_JS.contains("__mnoteDocumentPaneRuntime?.openPrimaryMindmap")); - assert!(SIDEBAR_TREE_JS.contains("shortMindmapFileName")); - assert!(SIDEBAR_TREE_JS.contains("assetType: assetType || null")); - assert!(SIDEBAR_TREE_JS.contains("data-object-identity")); - assert!(SIDEBAR_TREE_JS.contains("readFileTreeObjectIdentity")); - assert!(SIDEBAR_TREE_JS.contains("objectIdentity: objectIdentity")); - assert!(SIDEBAR_TREE_JS.contains("workspaceId: resolveWorkspaceId(fileRow)")); - assert!(SIDEBAR_TREE_JS.contains("/api/media/sign?assetId=")); - assert!(SIDEBAR_TREE_JS.contains("function localFilePathFromAssetId")); - assert!(SIDEBAR_TREE_JS.contains("/api/local-folder/files/open")); - assert!(SIDEBAR_TREE_JS.contains("local-file:")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("wolai:assets-changed")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("applyAssetsChangedToFileTree")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("installMindmapAssetFetchObserver")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("parseMindmapApiTarget")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-assets-local-applied")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("application/x-mnote-page-tree-node")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("dragstart")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("drop")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-drop-feedback")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("action: 'move'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebar-file-tree-root")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.open")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.asset.open")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openConvexAssetFromFileTree")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildMindmapOpenPath")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("isMindmapAssetDetail")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("^思维导图")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openMindmapAssetInDocumentShell")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-last-mindmap-asset-open-mode")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mindmap-object-shell")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("navigateToMindmapObject(documentId, assetId")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("__mnoteDocumentPaneRuntime?.openPrimaryMindmap")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("shortMindmapFileName")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("assetType: assetType || null")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-object-identity")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("readFileTreeObjectIdentity")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("objectIdentity: objectIdentity")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("workspaceId: resolveWorkspaceId(fileRow)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/media/sign?assetId=")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function localFilePathFromAssetId")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/local-folder/files/open")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("local-file:")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("value.indexOf('local:asset:') === 0 ? value.slice('local:asset:'.length)")); - assert!(SIDEBAR_TREE_JS.contains("fetchCurrentOnlyOfficeUserId")); - assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami")); - assert!(SIDEBAR_TREE_JS.contains("buildOnlyOfficeOpenUrl")); - assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('userId'")); - assert!(SIDEBAR_TREE_JS.contains("window.open(officeUrl,")); - assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop")); - assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop")); - assert!(SIDEBAR_TREE_JS.contains("beginFileTreeInlineRename")); - assert!(SIDEBAR_TREE_JS.contains("tree-rename-input")); - assert!(SIDEBAR_TREE_JS.contains("sidebarFileTreeClipboard")); - assert!(SIDEBAR_TREE_JS.contains("pasteSidebarFileTreeClipboard")); - assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX")); - assert!(SIDEBAR_TREE_JS.contains("function recentLocalRootsStorageKey")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-actor-id")); - assert!(SIDEBAR_TREE_JS.contains("requestOpenLocalFolder")); - assert!(SIDEBAR_TREE_JS.contains("autoOpenRecentLocalRootOnHome")); - assert!(SIDEBAR_TREE_JS.contains("sourceKind', 'local_folder")); - assert!(SIDEBAR_TREE_JS.contains("switchToCloudWorkspace")); - assert!(SIDEBAR_TREE_JS.contains("mnote-switch-cloud-workspace")); - assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root")); - assert!(SIDEBAR_TREE_JS.contains("mnote-local-folder-authorized-roots")); - assert!(SIDEBAR_TREE_JS.contains("mnote-local-folder-authorized-root")); - assert!(SIDEBAR_TREE_JS.contains("已授权文件夹")); - assert!(SIDEBAR_TREE_JS.contains("fetch('/api/user/access-policy'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fetchCurrentOnlyOfficeUserId")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/auth/whoami")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildOnlyOfficeOpenUrl")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("target.searchParams.set('userId'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.open(officeUrl,")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.internal-drop")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.external-drop")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("beginFileTreeInlineRename")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree-rename-input")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarFileTreeClipboard")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pasteSidebarFileTreeClipboard")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function recentLocalRootsStorageKey")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-actor-id")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("requestOpenLocalFolder")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("autoOpenRecentLocalRootOnHome")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sourceKind', 'local_folder")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("switchToCloudWorkspace")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-switch-cloud-workspace")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-recent-local-root")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-local-folder-authorized-roots")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-local-folder-authorized-root")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("已授权文件夹")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fetch('/api/user/access-policy'")); } #[test] fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() { assert!( - !SIDEBAR_TREE_JS.contains("pageSubtreeSource: 'local'"), + !SIDEBAR_TREE_RUNTIME_JS.contains("pageSubtreeSource: 'local'"), "页面 AI context 不能从编辑器 DOM 派生 local page subtree;必须以 Rust Page Aggregate projection 为准" ); assert!( - !SIDEBAR_TREE_JS.contains("buildPageAiLocalSubtree(localBlocks"), + !SIDEBAR_TREE_RUNTIME_JS.contains("buildPageAiLocalSubtree(localBlocks"), "页面 AI context 不应调用本地 page subtree builder" ); } @@ -10900,132 +327,133 @@ mod tests { #[test] fn page_ai_fast_path_is_not_local_first_main_path() { assert!( - SIDEBAR_TREE_JS.contains("if (currentSourceKind() === 'local_folder') return false;"), + SIDEBAR_TREE_RUNTIME_JS + .contains("if (currentSourceKind() === 'local_folder') return false;"), "local-first 页面 AI 不应继续加厚 page-ai fast-path;本地编辑应走受控文件工具" ); } #[test] fn page_ai_local_source_passes_file_reference_fields_to_agent_run() { - assert!(SIDEBAR_TREE_JS.contains("function currentRootUri()")); - assert!(SIDEBAR_TREE_JS.contains("sourceKind: currentSourceKind()")); - assert!(SIDEBAR_TREE_JS.contains("rootUri: currentRootUri()")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function currentRootUri()")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sourceKind: currentSourceKind()")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("rootUri: currentRootUri()")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageContext: scopedContext.pageContext")); assert!( - SIDEBAR_TREE_JS.contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri") - ); - assert!(SIDEBAR_TREE_JS.contains("pageContext: scopedContext.pageContext")); - assert!( - SIDEBAR_TREE_JS.contains("if (currentSourceKind() === 'local_folder') return false;"), + SIDEBAR_TREE_RUNTIME_JS + .contains("if (currentSourceKind() === 'local_folder') return false;"), "local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope" ); } #[test] fn page_ai_uses_backend_acp_session_runtime_store() { - assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessions")); - assert!(SIDEBAR_TREE_JS.contains("/api/hermes/client/sessions?")); - assert!(SIDEBAR_TREE_JS.contains("params.set('source', 'acp')")); - assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessionDetail")); - assert!(SIDEBAR_TREE_JS.contains("function pageAiSearchBackendSessions")); - assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-rename")); - assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-delete")); - assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-resume")); - assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-search")); - assert!(SIDEBAR_TREE_JS.contains("pageAiUsageSummary")); - assert!(SIDEBAR_TREE_JS.contains("usage.updated")); - assert!(SIDEBAR_TREE_JS.contains("thought.delta")); - assert!(SIDEBAR_TREE_JS.contains("permission.requested")); - assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"allow\"")); - assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"deny\"")); - assert!(SIDEBAR_TREE_JS.contains("function pageAiHidePermissionDialog")); - assert!(SIDEBAR_TREE_JS.contains("if (!message.resolved)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiLoadBackendSessions")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/hermes/client/sessions?")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("params.set('source', 'acp')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiSearchBackendSessions")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-session-rename")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-session-delete")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-session-resume")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-session-search")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiUsageSummary")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("usage.updated")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("thought.delta")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("permission.requested")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-permission-action=\"allow\"")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-permission-action=\"deny\"")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiHidePermissionDialog")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (!message.resolved)")); assert!( - !SIDEBAR_TREE_JS.contains("(item.resolved ? ' disabled' : '')"), + !SIDEBAR_TREE_RUNTIME_JS.contains("(item.resolved ? ' disabled' : '')"), "已决 ACP permission 事件不能继续展示假审批按钮" ); assert!( - SIDEBAR_TREE_JS.contains("session.info.updated"), + SIDEBAR_TREE_RUNTIME_JS.contains("session.info.updated"), "ACP SessionInfoUpdate 事件应通过 session.info.updated SSE 转发到前端" ); assert!( - SIDEBAR_TREE_JS.contains("plan.updated"), + SIDEBAR_TREE_RUNTIME_JS.contains("plan.updated"), "ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端" ); assert!( - SIDEBAR_TREE_JS.contains("data-page-ai-plan"), + SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-plan"), "plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板" ); assert!( - SIDEBAR_TREE_JS.contains("执行计划 · "), + SIDEBAR_TREE_RUNTIME_JS.contains("执行计划 · "), "plan 面板标题应显示执行计划和步数" ); } #[test] fn page_ai_session_ui_labels_local_shared_and_cloud_storage() { - assert!(SIDEBAR_TREE_JS.contains("function pageAiSessionStorageLabel")); - assert!(SIDEBAR_TREE_JS.contains("local_private")); - assert!(SIDEBAR_TREE_JS.contains("local_shared")); - assert!(SIDEBAR_TREE_JS.contains("convex_acp_runtime_store")); - assert!(SIDEBAR_TREE_JS.contains("本地私有")); - assert!(SIDEBAR_TREE_JS.contains("共享会话")); - assert!(SIDEBAR_TREE_JS.contains("云端会话")); - assert!(SIDEBAR_TREE_JS.contains("sessionStorage:")); - assert!(SIDEBAR_TREE_JS.contains("permissionLevel:")); - assert!(SIDEBAR_TREE_JS.contains("shareId:")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiSessionStorageLabel")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("local_private")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("local_shared")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("convex_acp_runtime_store")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("本地私有")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("共享会话")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("云端会话")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sessionStorage:")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("permissionLevel:")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("shareId:")); } #[test] fn page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch() { - assert!(SIDEBAR_TREE_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 3")); - assert!(SIDEBAR_TREE_JS.contains("pageAiAcpRuntime: 'reasonix'")); - assert!(SIDEBAR_TREE_JS.contains("function pageAiNormalizeAcpRuntimes")); - assert!(SIDEBAR_TREE_JS.contains("return ['reasonix', 'hermes']")); - assert!(SIDEBAR_TREE_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)")); - assert!(SIDEBAR_TREE_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 3")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("return ['reasonix', 'hermes']")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'")); - assert!(!SIDEBAR_TREE_JS.contains("默认 (Hermes HTTP)")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("默认 (Hermes HTTP)")); } #[test] fn sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector() { assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot")); assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta")); - assert!(!SIDEBAR_TREE_JS.contains("usePreferredSidebarSnapshot")); - assert!(!SIDEBAR_TREE_JS.contains("preferredSidebarSnapshot")); - assert!(!SIDEBAR_TREE_JS.contains("liveSidebarTitle")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("usePreferredSidebarSnapshot")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("preferredSidebarSnapshot")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("liveSidebarTitle")); } #[test] fn page_layout_sidebar_toggle_is_wired_to_shell_state() { - assert!(SIDEBAR_TREE_JS.contains("function toggleWorkspaceSidebar")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-sidebar-collapsed")); - assert!(SIDEBAR_TREE_JS.contains("[data-mnote-action=\"toggle-sidebar\"]")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function toggleWorkspaceSidebar")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-sidebar-collapsed")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"toggle-sidebar\"]")); } #[test] fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() { assert!( - !SIDEBAR_TREE_JS.contains("rememberCloudWorkspaceId(resolveWorkspaceId(document.body))"), + !SIDEBAR_TREE_RUNTIME_JS.contains("rememberCloudWorkspaceId(resolveWorkspaceId(document.body))"), "进入本地文件夹前不能用 document.body 推导 workspaceId;它会在无 workspaceId URL 时回退成 default" ); assert!( - SIDEBAR_TREE_JS.contains("rememberCurrentCloudWorkspaceId()"), + SIDEBAR_TREE_RUNTIME_JS.contains("rememberCurrentCloudWorkspaceId()"), "进入本地文件夹前应从 URL 或 DOM 子树读取真实 workspaceId" ); assert!( - SIDEBAR_TREE_JS.contains("normalized.indexOf('local-ws:') === 0"), + SIDEBAR_TREE_RUNTIME_JS.contains("normalized.indexOf('local-ws:') === 0"), "本地文件夹 workspaceId 不能写入 lastCloudWorkspaceId" ); assert!( - SIDEBAR_TREE_JS.contains("stored.trim().indexOf('local-ws:') !== 0"), + SIDEBAR_TREE_RUNTIME_JS.contains("stored.trim().indexOf('local-ws:') !== 0"), "切回工作区时不能复用 local-ws:* 作为旧云 workspace" ); assert!( - !SIDEBAR_TREE_JS + !SIDEBAR_TREE_RUNTIME_JS .contains("targetUrl.searchParams.set('sourceKind', 'convex_workspace')"), "默认来源菜单不能跳到已退出 dev 主链的 convex_workspace" ); @@ -11033,30 +461,32 @@ mod tests { #[test] fn sidebar_tree_runtime_polls_local_folder_without_browser_reload() { - assert!(SIDEBAR_TREE_JS.contains("startLocalFolderSidebarWatch")); - assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot")); - assert!(SIDEBAR_TREE_JS.contains("function currentWorkspaceId()")); - assert!(SIDEBAR_TREE_JS.contains("var rootUri = currentRootUri();")); - assert!(SIDEBAR_TREE_JS.contains("/api/tree/local-folder-watch")); - assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/sidebar")); - assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/file")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("startLocalFolderSidebarWatch")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("refreshLocalFolderSidebarSnapshot")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function currentWorkspaceId()")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var rootUri = currentRootUri();")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/local-folder-watch")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/projections/sidebar")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/projections/file")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("renderSidebarSnapshot(sidebarPayload.result || sidebarPayload)")); - assert!(SIDEBAR_TREE_JS.contains("renderFileProjection(filePayload.result || filePayload)")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-local-folder-watch-applied")); - assert!(!SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("renderFileProjection(filePayload.result || filePayload)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied")); + assert!(!SIDEBAR_TREE_RUNTIME_JS .contains("fetch(window.location.href, { headers: { accept: 'text/html' } })")); - assert!(!SIDEBAR_TREE_JS + assert!(!SIDEBAR_TREE_RUNTIME_JS .contains("replaceSidebarTreeFromDocument(nextDocument, 'sidebar-tree-root')")); - assert!(!SIDEBAR_TREE_JS.contains("window.location.reload")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("window.location.reload")); } #[test] fn sidebar_upload_runtime_routes_local_markdown_assets_to_local_folder() { - let current_document_function = js_function_body(SIDEBAR_TREE_JS, "currentDocumentId"); - let upload_function = js_function_body(SIDEBAR_TREE_JS, "uploadFileToMediaAsset"); - assert!(SIDEBAR_TREE_JS.contains("currentSourceKind() === 'local_folder'")); - assert!(SIDEBAR_TREE_JS.contains("/api/local-folder/assets/upload")); + let current_document_function = + js_function_body(SIDEBAR_TREE_RUNTIME_JS, "currentDocumentId"); + let upload_function = js_function_body(SIDEBAR_TREE_RUNTIME_JS, "uploadFileToMediaAsset"); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("currentSourceKind() === 'local_folder'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/local-folder/assets/upload")); assert!( upload_function.contains("var rootUri = currentRootUri();"), "本地上传必须复用 currentRootUri(),否则 / 页面由 body dataset 提供 rootUri 时 .md 上传会失败" @@ -11070,36 +500,33 @@ mod tests { && current_document_function.contains("data-document-id"), "主编辑器上传应能从当前文档 DOM 回退解析 documentId" ); - assert!(SIDEBAR_TREE_JS.contains("localForm.append('rootUri', rootUri)")); - assert!(SIDEBAR_TREE_JS.contains("localForm.append('uploadIntent', uploadIntent)")); - assert!(SIDEBAR_TREE_JS.contains("uploadIntent: 'editor.markdown.attach'")); - assert!(SIDEBAR_TREE_JS.contains("uploadIntent: 'filetree.folder.drop'")); - assert!(SIDEBAR_TREE_JS.contains("localForm.append('documentId', documentId)")); - assert!(SIDEBAR_TREE_JS.contains("isLocalUploadedAsset(asset)")); - assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl")); - assert!(SIDEBAR_TREE_JS.contains("localOfficeUrl")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeIconKindForFileName(title)")); - assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot")); - assert!(SIDEBAR_TREE_JS.contains("wolai:local-assets-changed")); - assert!(SIDEBAR_TREE_JS.contains("function resolveEditorUploadContext")); - assert!( - SIDEBAR_TREE_JS.contains("localUploadRuntimeFunction('resolveEditorUploadContext')") - ); - assert!( - SIDEBAR_TREE_JS.contains("localUploadRuntimeFunction('openEditorUploadFilePicker')") - ); - assert!( - SIDEBAR_TREE_JS.contains("localUploadRuntimeFunction('insertUploadedAssetIntoEditor')") - ); - assert!(SIDEBAR_TREE_JS.contains("localUploadRuntimeFunction('uploadFileToMediaAsset')")); - assert!(SIDEBAR_TREE_JS.contains("localUploadRuntimeFunction('uploadMediaAsset')")); - assert!( - SIDEBAR_TREE_JS.contains("localUploadRuntimeFunction('uploadFilesWithResolvedTarget')") - ); - assert!(SIDEBAR_TREE_JS.contains("__mnoteLastEditorUploadRoot")); - assert!(SIDEBAR_TREE_JS.contains("data-pane-role=\"primary\"")); - assert!(SIDEBAR_TREE_JS.contains("insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options))")); - assert!(SIDEBAR_TREE_JS.contains("editorRoot: uploadContext.root")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("localForm.append('rootUri', rootUri)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("localForm.append('uploadIntent', uploadIntent)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("uploadIntent: 'editor.markdown.attach'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("uploadIntent: 'filetree.folder.drop'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("localForm.append('documentId', documentId)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("isLocalUploadedAsset(asset)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildLocalOnlyOfficeOpenUrl")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("localOfficeUrl")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeIconKindForFileName(title)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("refreshLocalFolderSidebarSnapshot")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("wolai:local-assets-changed")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function resolveEditorUploadContext")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("localUploadRuntimeFunction('resolveEditorUploadContext')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("localUploadRuntimeFunction('openEditorUploadFilePicker')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("localUploadRuntimeFunction('insertUploadedAssetIntoEditor')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("localUploadRuntimeFunction('uploadFileToMediaAsset')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("localUploadRuntimeFunction('uploadMediaAsset')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("localUploadRuntimeFunction('uploadFilesWithResolvedTarget')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("__mnoteLastEditorUploadRoot")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-pane-role=\"primary\"")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options))")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("editorRoot: uploadContext.root")); } #[test] @@ -11123,57 +550,70 @@ mod tests { #[test] fn sidebar_filetree_runtime_helpers_are_externalized_with_inline_fallback() { - assert!(SIDEBAR_TREE_JS.contains("function fileTreeRuntimeFunction")); - assert!(SIDEBAR_TREE_JS.contains("window.__mnoteFileTreeRuntime")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeRowDocumentId')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeRowAssetId')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeRowLocalRelativePath')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeAssetDownloadDetail')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeRowsByRowIds')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeChildCount')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('revealFileTreeRow')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('revealFileTreeAssetRow')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('appendUploadedAssetRow')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeUploadTargetFallback')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function fileTreeRuntimeFunction")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.__mnoteFileTreeRuntime")); assert!( - SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeRowsForUploadPreflight')") + SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('fileTreeRowDocumentId')") ); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('fileTreeRowAssetId')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('fileTreeRowLocalRelativePath')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('fileTreeAssetDownloadDetail')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('fileTreeRowsByRowIds')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('fileTreeChildCount')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('revealFileTreeRow')")); + assert!( + SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('revealFileTreeAssetRow')") + ); + assert!( + SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('appendUploadedAssetRow')") + ); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('fileTreeUploadTargetFallback')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('fileTreeRowsForUploadPreflight')")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("fileTreeRuntimeFunction('fileTreeDocumentParentsForPreflight')")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("fileTreeRuntimeFunction('fileTreeTargetChildrenForPreflight')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeDropPreflightRows')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('readProjection')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('readSidebarDataset')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('readDatasetProjection')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('projectionItems')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('hasProjectionItems')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('nodeIdOf')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('rowIdOf')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('parentIdOf')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('titleOf')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileWorkspaceRelativePath')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('groupRowsByParent')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('fileTreeDropPreflightRows')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('readProjection')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('readSidebarDataset')")); assert!( - SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('replaceSidebarTreeFromDocument')") + SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('readDatasetProjection')") ); - assert!( - SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('applyLocalFolderSidebarSnapshot')") - ); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('markLocalFolderWatchApplied')")); - let document_id_body = js_function_body(SIDEBAR_TREE_JS, "fileTreeRowDocumentId"); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('projectionItems')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('hasProjectionItems')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('nodeIdOf')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('rowIdOf')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('parentIdOf')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('titleOf')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('fileWorkspaceRelativePath')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('groupRowsByParent')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('replaceSidebarTreeFromDocument')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('applyLocalFolderSidebarSnapshot')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('markLocalFolderWatchApplied')")); + let document_id_body = js_function_body(SIDEBAR_TREE_RUNTIME_JS, "fileTreeRowDocumentId"); assert!( document_id_body.contains("data-document-id") && document_id_body.contains("data-doc-id"), "inline fallback 必须保留旧 data attribute 解析" ); - let replace_body = js_function_body(SIDEBAR_TREE_JS, "replaceSidebarTreeFromDocument"); + let replace_body = + js_function_body(SIDEBAR_TREE_RUNTIME_JS, "replaceSidebarTreeFromDocument"); assert!( replace_body.contains("current.innerHTML = next.innerHTML") && replace_body.contains("current.setAttribute(attr.name, attr.value)"), "inline fallback 必须保留 DOM copy 行为" ); - let apply_body = js_function_body(SIDEBAR_TREE_JS, "applyLocalFolderSidebarSnapshot"); + let apply_body = + js_function_body(SIDEBAR_TREE_RUNTIME_JS, "applyLocalFolderSidebarSnapshot"); assert!( apply_body.contains("options.pageRootId || 'sidebar-tree-root'") && apply_body.contains("options.fileRootId || 'sidebar-file-tree-root'") @@ -11181,7 +621,8 @@ mod tests { && apply_body.contains("replaceSidebarTreeFromDocument(nextDocument, fileRootId)"), "inline fallback 必须保留 sidebar/page tree DOM 应用行为" ); - let watch_applied_body = js_function_body(SIDEBAR_TREE_JS, "markLocalFolderWatchApplied"); + let watch_applied_body = + js_function_body(SIDEBAR_TREE_RUNTIME_JS, "markLocalFolderWatchApplied"); assert!( watch_applied_body.contains("data-mnote-local-folder-watch-applied") && watch_applied_body.contains("String(value || 'projection')"), @@ -11257,18 +698,19 @@ mod tests { assert!(FILETREE_RUNTIME_JS.contains("requestMethod: requestMethod")); assert!(FILETREE_RUNTIME_JS .contains("requestBodyHasMindmapCreateOnly: requestBodyHasMindmapCreateOnly")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("fileTreeRuntimeFunction('fileTreeDocumentWorkspacesForUploadPreflight')")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("fileTreeRuntimeFunction('fileTreeUploadTargetPreflightBody')")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("fileTreeRowsForUploadPreflight: fileTreeRowsForUploadPreflight")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('shortMindmapFileName')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('parseMindmapApiTarget')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('requestMethod')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('shortMindmapFileName')")); assert!( - SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('requestBodyHasMindmapCreateOnly')") + SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('parseMindmapApiTarget')") ); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeRuntimeFunction('requestMethod')")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeRuntimeFunction('requestBodyHasMindmapCreateOnly')")); assert!(FILETREE_RUNTIME_JS .contains("uploadIntent: String(detail.uploadIntent || 'filetree.folder.drop')")); assert!(FILETREE_RUNTIME_JS @@ -11277,25 +719,26 @@ mod tests { #[test] fn sidebar_filetree_selection_runtime_helpers_are_externalized_with_inline_fallback() { - assert!(SIDEBAR_TREE_JS.contains("function fileTreeSelectionRuntimeFunction")); - assert!(SIDEBAR_TREE_JS.contains("window.__mnoteFileTreeSelectionRuntime")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeSelectionRuntimeFunction('visibleFileTreeRows')")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function fileTreeSelectionRuntimeFunction")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.__mnoteFileTreeSelectionRuntime")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("fileTreeSelectionRuntimeFunction('visibleFileTreeRows')")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("fileTreeSelectionRuntimeFunction('syncSidebarFileTreeSelection')")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("fileTreeSelectionRuntimeFunction('selectSidebarFileTreeRow')")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRowIdsForDrag')")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRows')")); - let select_body = js_function_body(SIDEBAR_TREE_JS, "selectSidebarFileTreeRow"); + let select_body = js_function_body(SIDEBAR_TREE_RUNTIME_JS, "selectSidebarFileTreeRow"); assert!( select_body.contains("shiftKey && sidebarFileTreeSelection.anchorRowId") && select_body.contains("ctrlKey"), "inline fallback 必须保留 shift range 与 ctrl/meta toggle" ); let select_document_body = - js_function_body(SIDEBAR_TREE_JS, "selectSidebarFileTreeDocument"); + js_function_body(SIDEBAR_TREE_RUNTIME_JS, "selectSidebarFileTreeDocument"); assert!( select_document_body .contains("selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false })"), @@ -11318,211 +761,226 @@ mod tests { #[test] fn sidebar_tree_runtime_focuses_restored_local_folder_row() { - assert!(SIDEBAR_TREE_JS.contains("applyPendingLocalFolderRestoreFocusOnce")); - assert!(SIDEBAR_TREE_JS.contains("mnote.pendingLocalFolderRestoreFiletreeRowId")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-local-folder-restore-focused-row-id")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("applyPendingLocalFolderRestoreFocusOnce")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote.pendingLocalFolderRestoreFiletreeRowId")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-local-folder-restore-focused-row-id")); } #[test] fn sidebar_runtime_routes_local_mindmap_and_office_assets() { - assert!(SIDEBAR_TREE_JS.contains("withLocalMindmapSourceParams")); - assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('sourceKind', 'local_folder')")); - assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('rootUri', rootUri)")); - assert!(SIDEBAR_TREE_JS.contains("if (!parentRow && currentSourceKind() === 'local_folder' && objectKind === 'mindmap') return false;")); - assert!(SIDEBAR_TREE_JS.contains("if (!appended && currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("withLocalMindmapSourceParams")); + assert!( + SIDEBAR_TREE_RUNTIME_JS.contains("url.searchParams.set('sourceKind', 'local_folder')") + ); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("url.searchParams.set('rootUri', rootUri)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (!parentRow && currentSourceKind() === 'local_folder' && objectKind === 'mindmap') return false;")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (!appended && currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("data-mnote-last-mindmap-asset-open-mode', 'local-mindmap-object-shell'")); - assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl")); - assert!(SIDEBAR_TREE_JS.contains("isLocalAsset ? onlyOfficeUrl")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildLocalOnlyOfficeOpenUrl")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("isLocalAsset ? onlyOfficeUrl")); } #[test] fn sidebar_local_folder_authorized_root_normalizes_plain_path_root_uri() { - assert!(SIDEBAR_TREE_JS.contains("function normalizeGrantedLocalFolderRootUri(grant)")); - assert!(SIDEBAR_TREE_JS.contains("if (rootPath) return pathToFileRootUri(rootPath);")); - assert!(SIDEBAR_TREE_JS.contains("if (rootUri) return pathToFileRootUri(rootUri);")); assert!( - SIDEBAR_TREE_JS.contains("var rootUri = normalizeGrantedLocalFolderRootUri(grant);") + SIDEBAR_TREE_RUNTIME_JS.contains("function normalizeGrantedLocalFolderRootUri(grant)") ); - assert!(SIDEBAR_TREE_JS.contains("function isDefaultWorkspaceAutoGrant(grant)")); - assert!(SIDEBAR_TREE_JS.contains("if (isDefaultWorkspaceAutoGrant(grant)) return;")); - assert!(SIDEBAR_TREE_JS.contains("function recentLocalRootLabel(rootUri)")); - assert!(SIDEBAR_TREE_JS.contains("button.textContent = recentLocalRootLabel(rootUri);")); assert!( - !SIDEBAR_TREE_JS.contains("button.textContent = rootUri.replace(/^file:\\/\\//, '')") + SIDEBAR_TREE_RUNTIME_JS.contains("if (rootPath) return pathToFileRootUri(rootPath);") ); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (rootUri) return pathToFileRootUri(rootUri);")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("var rootUri = normalizeGrantedLocalFolderRootUri(grant);")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function isDefaultWorkspaceAutoGrant(grant)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (isDefaultWorkspaceAutoGrant(grant)) return;")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function recentLocalRootLabel(rootUri)")); + assert!( + SIDEBAR_TREE_RUNTIME_JS.contains("button.textContent = recentLocalRootLabel(rootUri);") + ); + assert!(!SIDEBAR_TREE_RUNTIME_JS + .contains("button.textContent = rootUri.replace(/^file:\\/\\//, '')")); } #[test] fn sidebar_runtime_supports_resizable_filetree_and_hover_titles() { - assert!(SIDEBAR_TREE_JS.contains("function installWorkspaceSidebarResizer()")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-sidebar-resizer")); - assert!(SIDEBAR_TREE_JS.contains("mnote.workspace.sidebarWidth.v1")); - assert!(SIDEBAR_TREE_JS.contains("shell.style.setProperty('--mnote-sidebar-width'")); - assert!(SIDEBAR_TREE_JS.contains("installWorkspaceSidebarResizer();")); - assert!(SIDEBAR_TREE_JS.contains(r#"" title="' + escapeHtml(title) + '""#)); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function installWorkspaceSidebarResizer()")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-sidebar-resizer")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote.workspace.sidebarWidth.v1")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("shell.style.setProperty('--mnote-sidebar-width'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("installWorkspaceSidebarResizer();")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains(r#"" title="' + escapeHtml(title) + '""#)); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains(r#"class="tree-link-title" title="' + escapeHtml(title) + '""#)); } #[test] fn sidebar_runtime_opens_local_pdf_assets_in_active_resource_tab_by_default() { - assert!(SIDEBAR_TREE_JS.contains("function shouldOpenLocalResourceInNewWindow(fileName)")); - assert!(SIDEBAR_TREE_JS.contains("return false;")); - assert!(SIDEBAR_TREE_JS.contains("var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("function shouldOpenLocalResourceInNewWindow(fileName)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("return false;")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({")); - assert!(SIDEBAR_TREE_JS.contains( + assert!(SIDEBAR_TREE_RUNTIME_JS.contains( "openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab'" )); } #[test] fn sidebar_tree_runtime_contains_dev_hot_reload_client() { - assert!(SIDEBAR_TREE_JS.contains("installMnoteDevHotReload")); - assert!(SIDEBAR_TREE_JS.contains("/api/dev/hot-reload")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-dev-hot-reload")); - assert!(SIDEBAR_TREE_JS.contains("window.clearInterval(timer)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("installMnoteDevHotReload")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/dev/hot-reload")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-dev-hot-reload")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.clearInterval(timer)")); } #[test] fn sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates() { - assert!(SIDEBAR_TREE_JS.contains("openTreeContextMenu")); - assert!(SIDEBAR_TREE_JS.contains("mnote-tree-context-menu")); - assert!(SIDEBAR_TREE_JS.contains("复制访问链接")); - assert!(SIDEBAR_TREE_JS.contains("删除到垃圾桶")); - assert!(SIDEBAR_TREE_JS.contains("function createFileTreeFolder")); - assert!(SIDEBAR_TREE_JS.contains("action: 'create_folder'")); - assert!(SIDEBAR_TREE_JS.contains("function fileTreeMenuTargetParentId")); - assert!(SIDEBAR_TREE_JS.contains("if (action === 'new-folder')")); - assert!(SIDEBAR_TREE_JS.contains("fileTreeCopyPath(detail, trigger)")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openTreeContextMenu")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-tree-context-menu")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("复制访问链接")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("删除到垃圾桶")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function createFileTreeFolder")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("action: 'create_folder'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function fileTreeMenuTargetParentId")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (action === 'new-folder')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreeCopyPath(detail, trigger)")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("if (detail.contextKind === 'filetree' && action === 'download')")); - assert!(SIDEBAR_TREE_JS.contains("downloadSelectedFileTreeAssetRows(detail, trigger)")); - assert!(SIDEBAR_TREE_JS.contains("function selectedSidebarFileTreeRowsForDownload")); - assert!(SIDEBAR_TREE_JS.contains("function selectedSidebarFileTreeAssetRowsForDownload")); - assert!(SIDEBAR_TREE_JS.contains("data-local-relative-path")); - assert!(SIDEBAR_TREE_JS.contains("targetRelativePath")); - assert!(SIDEBAR_TREE_JS.contains( + assert!( + SIDEBAR_TREE_RUNTIME_JS.contains("downloadSelectedFileTreeAssetRows(detail, trigger)") + ); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function selectedSidebarFileTreeRowsForDownload")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("function selectedSidebarFileTreeAssetRowsForDownload")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-local-relative-path")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("targetRelativePath")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains( "recordFileTreeAction(downloads.length > 1 ? 'bulk-download' : 'download', primary)" )); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-filetree-download-count")); - assert!(SIDEBAR_TREE_JS.contains("function triggerBrowserDownload")); - assert!(SIDEBAR_TREE_JS.contains("openEditorAttachmentMenu(editorAttachmentLink, editorAttachmentLink, { x: event.clientX, y: event.clientY });")); - assert!(SIDEBAR_TREE_JS.contains("var localFilePath = localFilePathFromAssetId(assetId);")); - assert!(SIDEBAR_TREE_JS.contains("attachmentMetaCache[assetId] = localMeta;")); - assert!(SIDEBAR_TREE_JS.contains( + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-filetree-download-count")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function triggerBrowserDownload")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openEditorAttachmentMenu(editorAttachmentLink, editorAttachmentLink, { x: event.clientX, y: event.clientY });")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("var localFilePath = localFilePathFromAssetId(assetId);")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("attachmentMetaCache[assetId] = localMeta;")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains( "var pageRow = closestAction(e.target, '.tree-row[data-shell-mode=\"page\"]');" )); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("var action = btn ? btn.getAttribute('data-rust-action') : 'open';")); - assert!(SIDEBAR_TREE_JS.contains("var openTrigger = btn || pageRow;")); - assert!(SIDEBAR_TREE_JS.contains("var workspaceId = resolveWorkspaceId(openTrigger);")); - assert!(SIDEBAR_TREE_JS.contains( + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var openTrigger = btn || pageRow;")); + assert!( + SIDEBAR_TREE_RUNTIME_JS.contains("var workspaceId = resolveWorkspaceId(openTrigger);") + ); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains( r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"# )); - assert!(SIDEBAR_TREE_JS.contains( + assert!(SIDEBAR_TREE_RUNTIME_JS.contains( r#".tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title"# )); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains(r#"[data-page-title-input="true"][data-document-id="' + escaped + '"]"#)); - assert!(SIDEBAR_TREE_JS.contains(r#".wolai-breadcrumb-current [data-page-title-current]"#)); - assert!(!SIDEBAR_TREE_JS.contains( + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains(r#".wolai-breadcrumb-current [data-page-title-current]"#)); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains( r#".tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title"# )); - assert!(!SIDEBAR_TREE_JS + assert!(!SIDEBAR_TREE_RUNTIME_JS .contains(r#"[data-node-id="' + cssEscape(documentId) + '"] .tree-link-title"#)); - assert!(SIDEBAR_TREE_JS.contains("renderSidebarSnapshot")); - assert!(SIDEBAR_TREE_JS.contains("kernel_file_tree_projection")); - assert!(SIDEBAR_TREE_JS.contains("deltaNeedsProjectionRefresh")); - assert!(SIDEBAR_TREE_JS.contains("upsert_documents")); - assert!(SIDEBAR_TREE_JS.contains("setTreeLiveApplyError")); - assert!(!SIDEBAR_TREE_JS.contains("scheduleProjectionRefresh")); - assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/sidebar")); - assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/file")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("renderSidebarSnapshot")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("kernel_file_tree_projection")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("deltaNeedsProjectionRefresh")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("upsert_documents")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("setTreeLiveApplyError")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("scheduleProjectionRefresh")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/projections/sidebar")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/projections/file")); } #[test] fn sidebar_filetree_runtime_uses_markdown_page_row_without_local_index_child() { - assert!(SIDEBAR_TREE_JS.contains("fileTreePageTitle(title)")); - assert!(SIDEBAR_TREE_JS.contains("normalizeFileTreePageRenameTitle")); - assert!(SIDEBAR_TREE_JS.contains("validateFileTreeRename")); - assert!(SIDEBAR_TREE_JS.contains("同级已存在同名页面")); - assert!(SIDEBAR_TREE_JS.contains("文件名不能包含")); - assert!(SIDEBAR_TREE_JS.contains("rowId === 'doc:' + activeId")); - assert!(SIDEBAR_TREE_JS.contains("currentFileTreeActiveRowId")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fileTreePageTitle(title)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("normalizeFileTreePageRenameTitle")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("validateFileTreeRename")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("同级已存在同名页面")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("文件名不能包含")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("rowId === 'doc:' + activeId")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("currentFileTreeActiveRowId")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("window.location.pathname.match(/^\\/mindmap\\/([^\\/]+)\\/([^\\/]+)/)")); - assert!(SIDEBAR_TREE_JS.contains( + assert!(SIDEBAR_TREE_RUNTIME_JS.contains( "var selected = activeRowId ? rowId === activeRowId : rowId === 'doc:' + activeId" )); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("renderFileRows('', groupRowsByParent(rows), activeId, activeRowId)")); - assert!(SIDEBAR_TREE_JS.contains("renderFileRows(nodeId, grouped, activeId, activeRowId)")); - assert!(SIDEBAR_TREE_JS.contains("normalizeMindmapFileTreeTitle")); - assert!(SIDEBAR_TREE_JS.contains("shortMindmapFileName(assetId)")); - assert!( - SIDEBAR_TREE_JS.contains("var title = isFileTreeProjectionPageRow(rowKind, assetId)") - ); - assert!(!SIDEBAR_TREE_JS.contains("title: 'index.md'")); - assert!(!SIDEBAR_TREE_JS.contains("rowId === 'index:' + activeId")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("renderFileRows(nodeId, grouped, activeId, activeRowId)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("normalizeMindmapFileTreeTitle")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("shortMindmapFileName(assetId)")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("var title = isFileTreeProjectionPageRow(rowKind, assetId)")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("title: 'index.md'")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("rowId === 'index:' + activeId")); } #[test] fn sidebar_tree_delete_to_trash_dispatches_archive_not_purge() { - let delete_trash_start = SIDEBAR_TREE_JS + let delete_trash_start = SIDEBAR_TREE_RUNTIME_JS .find("if (action === 'delete-trash'") .expect("delete-trash branch"); - let delete_trash_end = SIDEBAR_TREE_JS[delete_trash_start..] + let delete_trash_end = SIDEBAR_TREE_RUNTIME_JS[delete_trash_start..] .find("function appendTreeContextMenuButton") .map(|offset| delete_trash_start + offset) .expect("delete-trash branch end"); - let delete_trash_branch = &SIDEBAR_TREE_JS[delete_trash_start..delete_trash_end]; + let delete_trash_branch = &SIDEBAR_TREE_RUNTIME_JS[delete_trash_start..delete_trash_end]; assert!(delete_trash_branch.contains("action: 'archive'")); assert!(!delete_trash_branch.contains("action: 'purge'")); } #[test] fn sidebar_filetree_delete_keys_support_mixed_doc_and_asset_selection() { - assert!(SIDEBAR_TREE_JS.contains("function deleteSelectedSidebarFileTreeRows")); - assert!(SIDEBAR_TREE_JS.contains("event.key === 'Delete' || event.key === 'Backspace'")); - assert!(SIDEBAR_TREE_JS.contains("selectedFileTreeRows.length > 1")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function deleteSelectedSidebarFileTreeRows")); assert!( - SIDEBAR_TREE_JS.contains("deleteSelectedSidebarFileTreeRows(trigger || document.body)") + SIDEBAR_TREE_RUNTIME_JS.contains("event.key === 'Delete' || event.key === 'Backspace'") ); - assert!(SIDEBAR_TREE_JS.contains( + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("selectedFileTreeRows.length > 1")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("deleteSelectedSidebarFileTreeRows(trigger || document.body)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains( "currentSourceKind() === 'local_folder' && fileTreeRowKind(row) === 'asset'" )); - assert!(SIDEBAR_TREE_JS.contains("if (currentSourceKind() === 'local_folder')")); - assert!(SIDEBAR_TREE_JS.contains("documentId: fileAssetIds[lf]")); - assert!(SIDEBAR_TREE_JS.contains("/api/media/batch")); - assert!(SIDEBAR_TREE_JS.contains("/api/mindmap/")); - assert!(SIDEBAR_TREE_JS.contains("/api/tables/")); - assert!(SIDEBAR_TREE_JS.contains("确认删除选中的 ")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (currentSourceKind() === 'local_folder')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("documentId: fileAssetIds[lf]")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/media/batch")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/mindmap/")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tables/")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("确认删除选中的 ")); } #[test] fn sidebar_filetree_blocks_readonly_paste_and_drop_with_action_status() { - assert!(SIDEBAR_TREE_JS.contains("function blockReadonlyFileTreeAction")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-filetree-readonly-blocked")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-filetree-readonly-message")); - assert!(SIDEBAR_TREE_JS.contains("recordFileTreeActionStatus('blocked'")); - assert!(SIDEBAR_TREE_JS.contains("/api/tree/filetree/drop-preflight")); - assert!(SIDEBAR_TREE_JS.contains("ensureFileTreeWritableTarget('paste'")); - assert!(SIDEBAR_TREE_JS.contains("ensureFileTreeWritableTarget('drop'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function blockReadonlyFileTreeAction")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-filetree-readonly-blocked")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-filetree-readonly-message")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("recordFileTreeActionStatus('blocked'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/filetree/drop-preflight")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('paste'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'")); } #[test] fn sidebar_filetree_command_context_helper_functions_exist() { - assert!(SIDEBAR_TREE_JS.contains("function buildSidebarFileTreeContext")); - assert!(SIDEBAR_TREE_JS.contains("function evaluateSidebarFileTreeWhen")); - assert!(SIDEBAR_TREE_JS.contains("__mnoteFileTreeContextMenuRuntime")); - assert!(SIDEBAR_TREE_JS.contains("'workspace.sourceKind'")); - assert!(SIDEBAR_TREE_JS.contains("'workspace.readonly'")); - assert!(SIDEBAR_TREE_JS.contains("'tree.focusKind'")); - assert!(SIDEBAR_TREE_JS.contains("'tree.selectionCount'")); - assert!(SIDEBAR_TREE_JS.contains("'tree.selectionResourceKind'")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-workspace-readonly")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function buildSidebarFileTreeContext")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function evaluateSidebarFileTreeWhen")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("__mnoteFileTreeContextMenuRuntime")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'workspace.sourceKind'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'workspace.readonly'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'tree.focusKind'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'tree.selectionCount'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'tree.selectionResourceKind'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-workspace-readonly")); } #[test] @@ -11540,32 +998,35 @@ mod tests { fn sidebar_filetree_context_menu_items_have_when_for_readonly() { // FileTree 分支的写操作必须带 when: '!workspace.readonly'。 assert!( - SIDEBAR_TREE_JS.contains("when: '!workspace.readonly'"), + SIDEBAR_TREE_RUNTIME_JS.contains("when: '!workspace.readonly'"), "上下文菜单写操作应携带 when: '!workspace.readonly'" ); // Verify at least one delete-trash item has the when expression - let delete_trash_items = SIDEBAR_TREE_JS.match_indices("'delete-trash'").count(); + let delete_trash_items = SIDEBAR_TREE_RUNTIME_JS + .match_indices("'delete-trash'") + .count(); assert!(delete_trash_items > 0, "应至少有一个 delete-trash 菜单项"); } #[test] fn sidebar_filetree_context_evaluator_uses_readonly_and_selection_count_for_delete_key() { assert!( - SIDEBAR_TREE_JS.contains("evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')"), + SIDEBAR_TREE_RUNTIME_JS + .contains("evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')"), "Delete/Backspace 快捷键应至少评估 workspace.readonly 上下文" ); } #[test] fn sidebar_filetree_local_table_bulk_delete_uses_tree_command() { - let table_loop_start = SIDEBAR_TREE_JS + let table_loop_start = SIDEBAR_TREE_RUNTIME_JS .find("for (var t = 0; t < plan.tableRows.length; t += 1)") .expect("table bulk delete loop"); - let table_loop_end = SIDEBAR_TREE_JS[table_loop_start..] + let table_loop_end = SIDEBAR_TREE_RUNTIME_JS[table_loop_start..] .find("sidebarFileTreeSelection.selectedRowIds") .map(|offset| table_loop_start + offset) .expect("table bulk delete loop end"); - let table_loop = &SIDEBAR_TREE_JS[table_loop_start..table_loop_end]; + let table_loop = &SIDEBAR_TREE_RUNTIME_JS[table_loop_start..table_loop_end]; assert!(table_loop.contains("currentSourceKind() === 'local_folder'")); assert!(table_loop.contains("dispatchTreeCommand(trigger || tableRow")); assert!(table_loop.contains("action: 'archive'")); @@ -11576,21 +1037,22 @@ mod tests { #[test] fn sidebar_filetree_asset_open_uses_owner_document_id() { assert!( - SIDEBAR_TREE_JS.contains( + SIDEBAR_TREE_RUNTIME_JS.contains( "var ownerDocumentId = fileRow.getAttribute('data-owner-document-id') || documentId;" ), "打开资源行时必须优先使用资源 owner document,不能用当前页面 documentId" ); assert!( - SIDEBAR_TREE_JS.contains("documentId: ownerDocumentId || null"), + SIDEBAR_TREE_RUNTIME_JS.contains("documentId: ownerDocumentId || null"), "tree.asset.open detail 应携带资源 owner document" ); assert!( - SIDEBAR_TREE_JS.contains("data-owner-document-id=\"' + escapeHtml(ownerDocumentId)"), + SIDEBAR_TREE_RUNTIME_JS + .contains("data-owner-document-id=\"' + escapeHtml(ownerDocumentId)"), "SSR filetree 行应输出 data-owner-document-id" ); assert!( - SIDEBAR_TREE_JS + SIDEBAR_TREE_RUNTIME_JS .contains("return 'local-md:' + bundleName + '~2F' + bundleName + '.md';"), "local_folder bundle 资源应从 local-file 路径推导 owner markdown document" ); @@ -11598,66 +1060,68 @@ mod tests { #[test] fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() { - assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType")); - assert!(SIDEBAR_TREE_JS.contains("isNonOfficeAttachmentName(name, ext)")); - assert!(!SIDEBAR_TREE_JS.contains("if (ext === 'pdf') return ext;")); - assert!(!SIDEBAR_TREE_JS.contains("mt.indexOf('pdf') >= 0")); - assert!(SIDEBAR_TREE_JS.contains("mnote-uploaded-attachment-pdf")); - assert!(SIDEBAR_TREE_JS.contains("mnote-uploaded-attachment-code")); - assert!(SIDEBAR_TREE_JS.contains("'toml'")); - assert!(SIDEBAR_TREE_JS.contains("'json'")); - assert!(SIDEBAR_TREE_JS.contains("'yaml'")); - assert!(SIDEBAR_TREE_JS.contains("'md'")); - assert!(SIDEBAR_TREE_JS.contains("'vue'")); - assert!(SIDEBAR_TREE_JS.contains("'svelte'")); - assert!(SIDEBAR_TREE_JS.contains("'proto'")); - assert!(SIDEBAR_TREE_JS.contains("'dockerfile'")); - assert!(SIDEBAR_TREE_JS.contains("'.gitignore'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function inferOnlyOfficeFileType")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("isNonOfficeAttachmentName(name, ext)")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("if (ext === 'pdf') return ext;")); + assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("mt.indexOf('pdf') >= 0")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-uploaded-attachment-pdf")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-uploaded-attachment-code")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'toml'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'json'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'yaml'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'md'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'vue'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'svelte'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'proto'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'dockerfile'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("'.gitignore'")); } #[test] fn sidebar_tree_runtime_opens_office_assets_through_resource_shell() { - assert!(SIDEBAR_TREE_JS.contains("function buildOnlyOfficeOpenPath")); - assert!(SIDEBAR_TREE_JS.contains( + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function buildOnlyOfficeOpenPath")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains( "return '/office/' + encodeURIComponent(input.documentId) + '/' + encodeURIComponent(input.assetId)" )); - assert!(SIDEBAR_TREE_JS.contains("return '/onlyoffice?' + params.toString();")); - assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('mode', input.mode || 'view');")); - assert!(SIDEBAR_TREE_JS.contains("params.set('mode', input.mode || 'view');")); - assert!(SIDEBAR_TREE_JS.contains( + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("return '/onlyoffice?' + params.toString();")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("target.searchParams.set('mode', input.mode || 'view');")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("params.set('mode', input.mode || 'view');")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains( "{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' }" )); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("{ action: 'open-edit-mode', icon: 'edit_note', label: '弹窗编辑' }")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("{ action: 'new-window-edit', icon: 'open_in_new', label: '新窗口编辑' }")); - assert!(SIDEBAR_TREE_JS.contains("function withOfficeEditModeGuard(callback)")); - assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-office-edit-mode-requested")); - assert!(SIDEBAR_TREE_JS.contains("function openEditorAttachmentEditTab(detail)")); - assert!(SIDEBAR_TREE_JS.contains("void openEditorAttachmentEditTab(detail);")); - assert!(SIDEBAR_TREE_JS.contains("openLocalOfficeFileInActiveTab(detail, 'edit')")); - assert!(SIDEBAR_TREE_JS.contains("openEditorAttachmentNewWindow(detail, 'edit')")); - assert!(SIDEBAR_TREE_JS.contains("forceEditMode ? 'edit' : 'view'")); - assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('mode', requestedMode);")); - assert!(SIDEBAR_TREE_JS.contains("mode: requestedMode")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function withOfficeEditModeGuard(callback)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-last-office-edit-mode-requested")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openEditorAttachmentEditTab(detail)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("void openEditorAttachmentEditTab(detail);")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openLocalOfficeFileInActiveTab(detail, 'edit')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openEditorAttachmentNewWindow(detail, 'edit')")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("forceEditMode ? 'edit' : 'view'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("url.searchParams.set('mode', requestedMode);")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mode: requestedMode")); } #[test] fn sidebar_tree_runtime_opens_pdf_and_code_assets_with_builtin_tools() { - assert!(SIDEBAR_TREE_JS.contains("function openPdfEditorAttachment")); - assert!(SIDEBAR_TREE_JS.contains("function openCodeEditorAttachment")); - assert!(SIDEBAR_TREE_JS.contains("function resolveEditorAttachmentUrl")); - assert!(SIDEBAR_TREE_JS.contains("var localFilePath = localFilePathFromAssetId(assetId)")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openPdfEditorAttachment")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openCodeEditorAttachment")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function resolveEditorAttachmentUrl")); + assert!(SIDEBAR_TREE_RUNTIME_JS + .contains("var localFilePath = localFilePathFromAssetId(assetId)")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true)")); - assert!(SIDEBAR_TREE_JS.contains("type: 'codeBlock'")); - assert!(SIDEBAR_TREE_JS.contains("attrs: { language: language }")); - assert!(SIDEBAR_TREE_JS.contains("inferCodeAttachmentLanguage")); - assert!(SIDEBAR_TREE_JS.contains("if (isPdfAttachmentFileName(detail.fileName))")); - assert!(SIDEBAR_TREE_JS.contains("if (isCodeAttachmentFileName(detail.fileName))")); - assert!(SIDEBAR_TREE_JS + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("type: 'codeBlock'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("attrs: { language: language }")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("inferCodeAttachmentLanguage")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (isPdfAttachmentFileName(detail.fileName))")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (isCodeAttachmentFileName(detail.fileName))")); + assert!(SIDEBAR_TREE_RUNTIME_JS .contains("if (isCodeAttachmentFileName(fileName) && String(asset.document_id")); - assert!(SIDEBAR_TREE_JS.contains("await openCodeEditorAttachment({")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("await openCodeEditorAttachment({")); } #[test] diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs index e1fe54bb..8a17e10c 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs @@ -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)); } diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/bridge_events.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/bridge_events.rs new file mode 100644 index 00000000..b18f8ac7 --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/bridge_events.rs @@ -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 +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, + pub(crate) workspace_id: Option, + pub(crate) title: String, + pub(crate) dirty_count: u32, + pub(crate) selected_block_index: Option, + pub(crate) current_block_id: Option, + 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, + pub(crate) workspace_id: Option, + pub(crate) title: String, + pub(crate) dirty_count: u32, + pub(crate) selected_block_index: Option, + 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, + pub(crate) revision: Option, + pub(crate) conflict_detection_key: Option, + pub(crate) read_only: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChangePayload { + pub(crate) document_id: Option, + pub(crate) workspace_id: Option, + 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, + pub(crate) runtime: Option, + pub(crate) version: Option, + pub(crate) source: Option, + pub(crate) event: Option, + pub(crate) payload: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimePageOptions { + pub(crate) wide_layout: Option, + pub(crate) small_text: Option, + pub(crate) layout_density: Option, + pub(crate) show_heading_numbers: Option, + pub(crate) embed_default_block_id: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HostCommandPayload { + pub(crate) command: Option, + pub(crate) document_id: Option, + pub(crate) workspace_id: Option, + pub(crate) title: Option, + pub(crate) content: Option, + pub(crate) editable: Option, + pub(crate) page_options: Option, + pub(crate) block_id: Option, + pub(crate) block_index: Option, + pub(crate) text: Option, + pub(crate) reference_document_id: Option, + pub(crate) reference_block_id: Option, + pub(crate) current_block_id: Option, + pub(crate) selection: Option, + pub(crate) revision: Option, + pub(crate) conflict_detection_key: Option, + pub(crate) read_only: Option, +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs index 303e205a..324f0e19 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs @@ -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}")) +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs new file mode 100644 index 00000000..40a49200 --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs @@ -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> = 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, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MindmapShellAction { + id: String, + label: String, + #[serde(default)] + long_label: Option, + icon: String, + #[serde(default)] + icon_key: Option, + #[serde(default)] + priority: Option, + #[serde(default)] + overflow_group: Option, + #[serde(default)] + cluster: Option, + disabled: bool, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MindmapShellToolbarGroup { + id: String, + label: String, + actions: Vec, +} + +#[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, + #[serde(default)] + body_items: Vec, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MindmapShellSidebarOption { + id: String, + label: String, + action_id: Option, + value: Value, + control_type: String, + #[serde(default)] + preview: Option, + #[serde(default)] + description: Option, + #[serde(default)] + readonly: bool, + compat_path: Option, +} + +#[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, + #[serde(default)] + visible_action_ids: Vec, + #[serde(default)] + overflow_action_ids: Vec, + #[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, + 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, + sidebar_panels: Vec, + 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(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! { + + } +} + +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! { +
+ {option_label} + {if option_description.is_empty() { + ().into_any() + } else { + view! { {option_description} }.into_any() + }} +
+ } + .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! { + + } + .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 = Vec::new(); + let mut overflow_toolbar_actions: Vec = Vec::new(); + let mut file_toolbar_actions: Vec = 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! { +
+ {if chrome_visible { + view! {
+
+
+ {primary_toolbar_actions + .into_iter() + .map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone())) + .collect_view()} +
+ {if has_toolbar_overflow { + let overflow_toggle_target = action_target.clone(); + let overflow_menu_actions = overflow_toolbar_actions.clone(); + view! { +
+ + {if toolbar_more_open { + view! { + + }.into_any() + } else { + ().into_any() + }} +
+ }.into_any() + } else { + ().into_any() + }} +
+ {file_toolbar_actions + .into_iter() + .map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone())) + .collect_view()} +
+
+
}.into_any() + } else { + ().into_any() + }} + {if chrome_visible { + view! { }.into_any() + } else { + ().into_any() + }} +
+ {format!("字数 {}", navigator.word_count)} + {format!("节点 {}", navigator.node_count)} +
+ {if chrome_visible { + view! {
+
+ +
+ + {if search_open { + view! { + + }.into_any() + } else { + ().into_any() + }} +
+ + +
+
+ + () { + 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::() { + 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::() { + input.set_value(&format!("{}%", navigator.zoom_percent)); + input.blur().ok(); + } + } + } + } + /> + + + +
+
}.into_any() + } else { + ().into_any() + }} + {if minimap_open { + view! { +
+
+ + + +
+
+
+ }.into_any() + } else { + ().into_any() + }} +
+ } +} + +fn take_mindmap_shell_handle(id: u32) -> Option { + MOUNTED_MINDMAP_SHELLS.with(|registry| registry.borrow_mut().remove(&id)) +} + +pub(crate) fn mount_mindmap_shell_impl(container: JsValue, options: JsValue) -> Result { + console_error_panic_hook::set_once(); + let options = serde_wasm_bindgen::from_value::(options)?; + let target = container + .dyn_into::() + .map_err(|_| JsValue::from_str("mindmap shell mount 目标必须是 HTML 元素"))?; + let mount_id = next_mount_id(); + let event_target: EventTarget = target.clone().into(); + let handle = mount_to(target, move || { + view! { + + } + }); + MOUNTED_MINDMAP_SHELLS.with(|registry| { + registry.borrow_mut().insert( + mount_id, + MountedMindmapShell { + mount_handle: Box::new(handle), + }, + ); + }); + Ok(mount_id) +} + +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 挂载句柄")) + } +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs index e9d07581..8bf62f74 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs @@ -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, diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index d2cd9cbe..da71f33a 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -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;")); } }