From ec623c9a829c2ce6c8a869d71282570da5554a22 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Mon, 25 May 2026 00:37:47 +0800 Subject: [PATCH] refactor: externalize filetree selection runtime --- ...eb-browser-runtime-module-extraction-v1.md | 125 +++++++++++++----- ...tap-editor-runtime-module-extraction-v1.md | 69 +++++++--- .../browser/filetree-selection-runtime.js | 110 +++++++++++++++ rust/crates/mnote-web/src/routes/mod.rs | 5 + rust/crates/mnote-web/src/routes/web_shell.rs | 14 ++ rust/crates/mnote-web/src/ssr/pages/layout.rs | 62 +++++++++ 6 files changed, 335 insertions(+), 50 deletions(-) rename design/03-rust-web/{process => done}/3-19-rust-web-browser-runtime-module-extraction-v1.md (83%) create mode 100644 rust/crates/mnote-web/browser/filetree-selection-runtime.js diff --git a/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md b/design/03-rust-web/done/3-19-rust-web-browser-runtime-module-extraction-v1.md similarity index 83% rename from design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md rename to design/03-rust-web/done/3-19-rust-web-browser-runtime-module-extraction-v1.md index 5cac3293..99e81f69 100644 --- a/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md +++ b/design/03-rust-web/done/3-19-rust-web-browser-runtime-module-extraction-v1.md @@ -1,7 +1,7 @@ # 3-19 Rust Web 浏览器 Runtime 模块外置化重构 v1 > 创建时间:2026-05-24 -> 状态:`process` +> 状态:`done` > > 触发背景: > - CodeGraph 对 `mnote-web` 前端内嵌 JS 的符号定位效果一般。 @@ -579,32 +579,89 @@ Reasonix read-only audit: - `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 -- --test-threads=1` - - 结果:9 个相关测试通过。 + - 结果:相关测试通过;本轮另以 `sidebar_filetree_runtime_helpers_are_externalized_with_inline_fallback`、`filetree_runtime_contains_row_accessor_helpers` 和完整 `filetree` 过滤测试复核。 - `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_context_menu -- --test-threads=1` - - 结果:2 个相关测试通过。 + - 结果:相关测试通过;本轮另以 `filetree_context` 过滤测试复核。 - `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1` - 结果:runtime asset route 测试通过。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree` + - 结果:29 个 filetree 相关测试通过。 +- `node scripts/task487-local-folder-tree-live-consumer-smoke.js` + - 结果:`ok=true`,证据文件 `tmp/task487-local-folder-tree-live-consumer-smoke/result.json`。 +- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` + - 结果:`ok=true`,覆盖 editor upload、filetree folder drop、resource tab missing/error、无 active document 附件 tab、filetree scroll、标题来源、附件删除保留相邻附件;证据包含 stdout JSON 和 smoke 生成截图。 +- `codegraph_search fileTreeRowLocalRelativePath` / `fileTreeAssetDownloadDetail` + - 结果:均可定位到 `rust/crates/mnote-web/browser/filetree-runtime.js`。 +- `codegraph_search buildSidebarFileTreeContext` / `evaluateSidebarFileTreeWhen` + - 结果:均可定位到 `rust/crates/mnote-web/browser/filetree-context-menu-runtime.js`。 - 真实浏览器轻量验收: - - dev server: `npm run dev:hot` + - dev server: 复用 / 启动 `MNOTE_WEB_BIND=0.0.0.0:3000 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3000 cargo run --manifest-path rust/Cargo.toml -p mnote-web --bin mnote-web` - 登录后访问 `http://127.0.0.1:3000/` - `document.scripts` 包含 `/api/mnote-browser-runtime/filetree-runtime.js` 与 `/api/mnote-browser-runtime/filetree-context-menu-runtime.js` - `window.__mnoteFileTreeRuntime.fileTreeRowLocalRelativePath` 和 `window.__mnoteFileTreeContextMenuRuntime.evaluateSidebarFileTreeWhen` 均为函数。 - 两个 runtime asset 均返回 `200`,`content-type=application/javascript; charset=utf-8`。 + - 截图证据:`p4-filetree-runtime-mounted.png`。 + +### 9.8 Batch 7 执行记录(2026-05-25) + +Reasonix read-only audit: + +- run id: `reasonix-2026-05-24T16-25-22-379Z-3e3df753` +- worktree: `/mnt/Data1T/mnote-worktrees/p4-filetree-selection-audit` +- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T16-25-22-379Z-3e3df753/process-handoff.md` +- 类型:只读审计,主控已读取 Hindsight recall、`process-handoff.md`、工作区 diff 与验证结果。 +- 采纳结论:主壳 filetree selection 应独立为 `filetree-selection-runtime.js`,不并入纯 accessor 的 `filetree-runtime.js`;DND、clipboard、keydown listener 和菜单 DOM 仍留后续批次。 + +采纳内容: + +- 新增 `rust/crates/mnote-web/browser/filetree-selection-runtime.js`。 +- 新增固定 asset route:`GET /api/mnote-browser-runtime/filetree-selection-runtime.js`。 +- `PageLayout` 在 `SIDEBAR_TREE_JS` 前注入 `type="module"` 外置脚本。 +- `SIDEBAR_TREE_JS` 中以下 helper 先做 runtime 代理,保留原实现 fallback: + - `visibleFileTreeRows` + - `syncSidebarFileTreeSelection` + - `selectSidebarFileTreeRow` + - `selectedSidebarFileTreeRowIdsForDrag` + - `selectedSidebarFileTreeRows` + +采纳边界: + +- 本批次不迁移 click / contextmenu / keydown / dragstart / dragover / drop / dragend 事件监听器。 +- 本批次不迁移 `pasteSidebarFileTreeClipboard`、`downloadSelectedFileTreeAssetRows`、`ensureFileTreeWritableTarget`、`deleteSelectedSidebarFileTreeRows`。 +- selection runtime 只承接选择状态同步、shift range、ctrl/meta toggle 和拖拽行 id 读取;文件树命令语义仍由主壳和 Rust API 合同决定。 + +已通过验证: + +- `node --check rust/crates/mnote-web/browser/filetree-selection-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_selection_runtime -- --test-threads=1` + - 结果:2 个 selection runtime 相关测试通过。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1` + - 结果:runtime asset route 测试通过。 +- 真实浏览器轻量验收: + - dev server: `MNOTE_WEB_BIND=0.0.0.0:3000 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3000 cargo run --manifest-path rust/Cargo.toml -p mnote-web --bin mnote-web` + - 临时 local folder 入口 `sourceKind=local_folder&treeView=filetree`。 + - `/api/mnote-browser-runtime/filetree-selection-runtime.js` 返回 `200`,`content-type=application/javascript; charset=utf-8`。 + - `window.__mnoteFileTreeSelectionRuntime.selectSidebarFileTreeRow` 为函数。 + - 对两个可见 filetree row 依次触发 click / ctrl-click 后,`data-selected="true"` 保留两行,`tree.filetree.selection.changed` 派发 2 次,最后事件包含两个 `selectedRowIds`。 ## 10. 分阶段执行清单 +归档说明(2026-05-25):本节保留原始分阶段路线,但不再作为当前 `done/` 状态的未完成 checklist。已经满足归档条件的范围以第 13 节 Done Gate 为准;仍未实施或未专项验证的条目改为普通状态项,后续需要另起设计或 bug/checklist 承接,不能在本 done 文档中继续以未勾选 checklist 表达。 + ### P0:定位辅助与基线保护 -- [ ] 记录当前 CodeGraph 对内嵌 JS 的限制,确认不更换 graph 项目。 -- [ ] 可选新增 `scripts/extract-embedded-js-for-index.js`,生成临时索引文件,不进入 runtime。 -- [ ] 先生成一份“热点函数归属表”,把最近 bug 中高频搜索的函数归到待拆模块: +- 已记录:当前 CodeGraph 对内嵌 JS 的限制,确认不更换 graph 项目。 +- 未采用:可选新增 `scripts/extract-embedded-js-for-index.js`,生成临时索引文件,不进入 runtime;本批通过真实模块外置优先解决定位问题。 +- 已记录:热点函数归属表,把最近 bug 中高频搜索的函数归到待拆模块: - `openResource` / `openLocalOfficeFileInActiveTab` / `openEditorAttachmentEditTab` -> `resource-open-runtime.js` - `executeLocalFileTreeExternalDrop` / `uploadFileToMediaAsset` / `insertUploadedAssetIntoEditor` -> `local-upload-runtime.js` - `startWithWebSocket` / `startWithSseFallback` -> `tree-live-controller.js` - `selectFileTreeRow` / `openFileTreeContextMenu` -> `filetree-selection-runtime.js` / `filetree-context-menu-runtime.js` - `createEditorViewBinding` / `replacePaneDocument` -> `document-pane-host-runtime.js`,归 `03-rust-web` 与 `05-editor-mainline` 交界,需单独切片。 -- [ ] 为 `layout.rs` / `tree.rs` 中计划迁出的 script 增加最小字符串快照测试,防止迁移时漏挂载。 -- [ ] 建立 smoke 基线: +- 后续:为 `layout.rs` / `tree.rs` 中计划迁出的 script 增加最小字符串快照测试,防止迁移时漏挂载。 +- 基线候选:建立 smoke 基线: - `node scripts/task487-local-folder-tree-live-consumer-smoke.js` - `node scripts/task446-tree-rename-dual-browser-live-smoke.js` - `node scripts/task472-side-target-secondary-pane-smoke.js` @@ -615,20 +672,20 @@ Reasonix read-only audit: - [x] 新增 `rust/crates/mnote-web/browser/resource-open-runtime.js`。 - [x] 新增 `rust/crates/mnote-web/browser/local-upload-runtime.js`,或先与 resource open 合并为单个不超过 800 行的 `resource-open-runtime.js`。 - [x] 第一刀只迁 `layout.rs` 中 resource open 的纯函数和 bridge;`web_shell.rs` 的 resource tab DOM、session、pane host 生命周期保持在后续 `document-pane-host-runtime.js` 批次。 -- [ ] 从 `SIDEBAR_TREE_JS` / `web_shell.rs` 中迁出资源打开路径适配,不改变现有 open intent: +- 后续:从 `SIDEBAR_TREE_JS` / `web_shell.rs` 中迁出资源打开路径适配,不改变现有 open intent: - page row -> 当前 pane page tab - filetree doc row -> Markdown page tab - local Markdown asset -> active resource tab / secondary pane - pdf/code -> 对应 lightweight viewer - office -> OnlyOffice path builder -- [ ] 从 `SIDEBAR_TREE_JS` 中迁出本地文件夹上传目标计算和 adapter 调用;资源归属语义仍由 Rust API / kernel 合同决定。 +- 后续:从 `SIDEBAR_TREE_JS` 中迁出本地文件夹上传目标计算和 adapter 调用;资源归属语义仍由 Rust API / kernel 合同决定。 - 2026-05-24 / `reasonix-2026-05-24T15-17-16-430Z-18054487` 只读审计:`leptos-tiptap` 只应发出上传 intent;本地 Markdown 路径、`rootUri`、`/api/local-folder/files/open` URL、`markdownRelativePath` 与真实上传落盘都归本设计的 shell host runtime。 - 2026-05-24 / Batch 4 已迁:`resolveEditorUploadContext`、`editorRootFromUploadOptions`、`openEditorUploadFilePicker`,并保持调用 `uploadFilesWithResolvedTarget`,未同时迁上传主流程。 - 暂不迁:`uploadFileToMediaAsset`、`uploadFilesWithResolvedTarget`、`insertUploadedAssetIntoEditor`。这些函数同时涉及 API POST、file tree refresh、Tiptap chain 插入、OnlyOffice/resource tab,需在后续批次继续拆边界。 -- [ ] 保持现有 smoke: +- 已保留/需按后续切片复跑:现有 smoke: - `node scripts/task472-side-target-secondary-pane-smoke.js` - `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` -- [ ] 验证 CodeGraph 或等价结构搜索能定位 `openResource`、`buildOnlyOfficeOpenPath`、`executeLocalFileTreeExternalDrop`。 +- 后续验证:CodeGraph 或等价结构搜索能定位 `openResource`、`buildOnlyOfficeOpenPath`、`executeLocalFileTreeExternalDrop`。 ### P2:外置 `TREE_LIVE_CONTROLLER_JS` @@ -639,8 +696,8 @@ Reasonix read-only audit: - [x] 浏览器断言: - `document.documentElement.dataset.mnoteTreeLiveTransport` 正确。 - 外置 module 实际执行并设置 `window.__mnoteTreeLiveControllerStarted`。 -- [ ] WS 推送仍更新 page/filetree。 -- [ ] WS 不可用时仍 fallback 到 SSE。 +- 后续专项验证:WS 推送仍更新 page/filetree。 +- 后续专项验证:WS 不可用时仍 fallback 到 SSE。 ### P3:外置 debug `/tree` shell runtime bridge @@ -653,13 +710,17 @@ Reasonix read-only audit: ### P4:抽出主壳 filetree runtime -- [ ] 从 `SIDEBAR_TREE_JS` 中拆出文件树选择、keyboard、clipboard、DND、context menu。 +- 部分完成/后续:从 `SIDEBAR_TREE_JS` 中拆出文件树选择、keyboard、clipboard、DND、context menu。 + - 2026-05-25 / Batch 6 已完成 context menu / filetree row helper 的首个外置切片。 + - 2026-05-25 / Batch 7 已完成 filetree selection helper 的首个外置切片。 + - 暂不迁:keyboard、clipboard、DND DOM 绑定、菜单 DOM、上传执行链。 - [x] 先拆纯函数和 reducer bridge,再拆 DOM 绑定。 - 2026-05-25 / Batch 6 已迁:filetree 行 accessor、local relative path、download detail、row lookup、child count wrapper,以及 context menu CommandContext / when evaluator。 - - 暂不迁:selection、keyboard、clipboard、DND、菜单 DOM、上传执行链。 -- [ ] 主壳保留 `window.__mnoteSidebarTreeRuntimeStarted` 一类启动防重保护,但启动函数迁到外置模块。 -- [ ] 与 Rust `FileTreeSelectionState` 合同对齐,不在前端新增第二套选择真相。 -- [ ] 覆盖: + - 2026-05-25 / Batch 7 已迁:visible rows、selection sync、shift range、ctrl/meta toggle、drag row id helper、selected rows helper。 + - 暂不迁:keyboard listener、clipboard action、DND listener、菜单 DOM、上传执行链。 +- 后续:主壳保留 `window.__mnoteSidebarTreeRuntimeStarted` 一类启动防重保护,但启动函数迁到外置模块。 +- 后续审计:与 Rust `FileTreeSelectionState` 合同对齐,不在前端新增第二套选择真相。 +- 后续覆盖: - 多选、shift range、ctrl/meta toggle。 - cut/copy/paste。 - 内部拖拽 move/copy。 @@ -668,13 +729,13 @@ Reasonix read-only audit: ### P5:抽出 document pane host adapter -- [ ] 从 `web_shell.rs` 的 inline module 中拆出文档 pane host adapter,例如: +- 后续:从 `web_shell.rs` 的 inline module 中拆出文档 pane host adapter,例如: - `document-pane-host-runtime.js` - `document-session-runtime.js` - `document-conflict-panel-runtime.js` -- [ ] 只迁 host/session/pane 生命周期,不迁 Tiptap 编辑器内部命令。 -- [ ] 与 `05-editor-mainline` 的编辑器 runtime 拆分保持边界:host 负责挂载、pane、session、资源 tab;editor 负责命令、history、附件链接、DOM overlay。 -- [ ] 覆盖: +- 后续:只迁 host/session/pane 生命周期,不迁 Tiptap 编辑器内部命令。 +- 后续:与 `05-editor-mainline` 的编辑器 runtime 拆分保持边界:host 负责挂载、pane、session、资源 tab;editor 负责命令、history、附件链接、DOM overlay。 +- 后续覆盖: - primary / secondary pane 切换 - conflict panel unmount 清理 - attachment tab 打开 @@ -682,11 +743,11 @@ Reasonix read-only audit: ### P6:收尾与旧 inline script 瘦身 -- [ ] `layout.rs` 中只保留小型 bootstrap 和脚本引用。 -- [ ] `tree.rs` 中不再内嵌大型 runtime JS。 -- [ ] `web_shell.rs` 中只保留 page aggregate/bootstrap JSON 和 module script 引用。 -- [ ] 更新 CodeGraph 索引,确认新增 JS 模块被索引。 -- [ ] 删除或降级 P0 提取脚本:如果真实模块已覆盖主要 runtime,提取脚本只保留为历史/临时工具。 +- 后续:`layout.rs` 中只保留小型 bootstrap 和脚本引用。 +- 后续:`tree.rs` 中不再内嵌大型 runtime JS。 +- 后续:`web_shell.rs` 中只保留 page aggregate/bootstrap JSON 和 module script 引用。 +- 已完成:更新 CodeGraph 索引,确认新增 JS 模块被索引。 +- 不适用:删除或降级 P0 提取脚本;本批未新增提取脚本。 ## 11. 测试与验收 @@ -756,11 +817,11 @@ node scripts/task487-local-folder-tree-live-consumer-smoke.js - [x] `TREE_LIVE_CONTROLLER_JS` 已从 `layout.rs` 迁出。 - [x] debug `/tree` shell runtime 大块 JS 已从 `tree.rs` 迁出,或已有明确豁免说明。 -- [ ] 主壳 filetree runtime 的核心 selection / DND / context menu 至少完成一个模块外置切片。 +- [x] 主壳 filetree runtime 的核心 selection / DND / context menu 至少完成一个模块外置切片。 - [x] resource open / local upload adapter 至少完成一个高频 bug 模块外置切片。 - [x] 新增 JS runtime 文件能被 CodeGraph 索引。 -- [ ] 至少一组 tree realtime smoke 和一组 local folder filetree/resource smoke 通过。 -- [ ] `layout.rs` / `web_shell.rs` 不再继续新增大型 inline browser runtime。 +- [x] 至少一组 tree realtime smoke 和一组 local folder filetree/resource smoke 通过。 +- [x] `layout.rs` / `web_shell.rs` 不再继续新增大型 inline browser runtime。 ## 14. 后续问题 diff --git a/design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md b/design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md index 531de71a..a08b0b3a 100644 --- a/design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md +++ b/design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md @@ -94,9 +94,11 @@ rust/spikes/leptos-tiptap-spike/src/ ## 7. 分阶段执行清单 +归档说明(2026-05-25):本节保留原始分阶段路线,但不再作为当前 `done/` 状态的未完成 checklist。已经满足归档条件的范围以第 13 节 Done Gate 为准;仍未实施或被明确划到 `03-rust-web` / 后续编辑器切片的条目改为普通状态项,后续需要另起设计或 bug/checklist 承接,不能在本 done 文档中继续以未勾选 checklist 表达。 + ### P0:热点函数归属表和基线保护 -- [ ] 列出当前 `src/lib.rs` 中最近 bug 高频函数的归属模块: +- 已记录:当前 `src/lib.rs` 中最近 bug 高频函数的归属模块: - `selected_local_attachment_link_in_editor_state` -> `attachment_links.rs` - `selected_local_attachment_link_text` -> `attachment_links.rs` / `dom_selection.rs` - `sync_persisted_editor_command` -> `command_sync.rs` @@ -105,8 +107,8 @@ rust/spikes/leptos-tiptap-spike/src/ - 2026-05-24:`delete_top_level_block_html` 已删除,块菜单删除改走 `delete_top_level_block_with_history(...)` -> Tiptap `delete_range` / `insert_content_at`。 - upload slash action / local asset path -> `attachment_upload.rs` / `local_markdown_paths.rs` - toolbar/block menu DOM anchor -> `overlays.rs` / `block_menu.rs` -- [ ] 建立 history 风险清单:所有用户编辑动作中仍调用 `set_content` 或 HTML 整文替换的路径必须登记。 -- [ ] 保留当前已通过的 `scripts/task488-local-attachment-link-delete-undo-smoke.js` 作为 RED/GREEN 基线。 +- 后续:建立 history 风险清单;所有用户编辑动作中仍调用 `set_content` 或 HTML 整文替换的路径必须登记。 +- 已保留:当前已通过的 `scripts/task488-local-attachment-link-delete-undo-smoke.js` 作为 RED/GREEN 基线。 ### P1:抽出附件链接和 history-safe delete @@ -128,7 +130,7 @@ rust/spikes/leptos-tiptap-spike/src/ - `sync_editor_outputs` - `sync_persisted_editor_command` - `apply_document_update`(当前保持零调用者现状) -- [ ] 迁出 change/status event dispatch 的编辑器侧包装。 +- 后续:迁出 change/status event dispatch 的编辑器侧包装。 - 2026-05-24 复核:`apply_html_update` 仍依赖 `ChangePayload` / `ChangeMetaPayload`、`dispatch_change_event`、`dispatch_runtime_state`、`HoveredBlockState` 和 toolbar overlay state,暂留 `lib.rs`,等待 P4 `bridge_events.rs` / `overlays.rs` 后再迁。 - [x] 明确 `set_content` 只允许用于: - 初始加载 @@ -141,25 +143,25 @@ rust/spikes/leptos-tiptap-spike/src/ ### P3:抽出附件上传与本地 Markdown 路径 - [x] 新增 `editor_runtime/attachment_upload.rs`。 -- [ ] 新增 `editor_runtime/local_markdown_paths.rs`。 +- 不纳入本设计当前归档范围:新增 `editor_runtime/local_markdown_paths.rs`。 - 2026-05-24 复核:`local_markdown_paths.rs` 不应落在 `leptos-tiptap` 内部;本地 Markdown 路径、`rootUri`、`/api/local-folder/files/open` URL 和上传落盘归 `03-rust-web` 的 shell host runtime / `local-upload-runtime.js`。 - [x] 迁出 slash 上传附件、图片的编辑器 intent。 - `dispatch_editor_upload_request(kind, accept)` 已迁入 `attachment_upload.rs`。 - `SlashActionKind::Image` / `UploadAttachment` 仍留在 slash menu 基础结构中,只调用 editor intent。 -- [ ] 迁出 Office/PDF/code 文件的编辑器内插入逻辑。 +- 不纳入本设计当前归档范围:迁出 Office/PDF/code 文件的编辑器内插入逻辑。 - 当前实际插入链路在 `layout.rs` / `local-upload-runtime.js`,不是 editor runtime;后续由 `03-rust-web` 继续收口。 - [x] 明确 editor 只负责发起上传 intent;资源保存位置和归属仍由 shell/Rust API 合同决定。 -- [ ] 覆盖: +- 后续覆盖: - 上传第一个/第二个附件后链接仍可点击。 - 刷新后链接不退化为普通外链。 - secondary pane 上传与 primary pane 隔离。 ### P4:抽出 DOM bridge / overlay / block menu -- [ ] 新增 `editor_runtime/dom_selection.rs`、`overlays.rs`、`block_menu.rs`。 -- [ ] 迁出 selection、toolbar anchor、slash menu anchor、block menu anchor、image/table toolbar anchor。 -- [ ] 对所有 keydown/click 捕获建立“谁先处理、谁 stop propagation”的规则,避免 Tiptap 原生快捷键与外层 window listener 互相吞事件。 -- [ ] 覆盖: +- 后续:新增 `editor_runtime/dom_selection.rs`、`overlays.rs`、`block_menu.rs`。 +- 后续:迁出 selection、toolbar anchor、slash menu anchor、block menu anchor、image/table toolbar anchor。 +- 后续:对所有 keydown/click 捕获建立“谁先处理、谁 stop propagation”的规则,避免 Tiptap 原生快捷键与外层 window listener 互相吞事件。 +- 后续覆盖: - `Ctrl+Z/Ctrl+Y` - slash menu - block menu @@ -168,19 +170,19 @@ rust/spikes/leptos-tiptap-spike/src/ ### P5:抽出 mindmap node view -- [ ] 新增 `editor_runtime/mindmap_node_view.rs`。 -- [ ] 迁出 mindmap block 的 node view、runtime mount、projection refresh、simplemindmap event bridge。 -- [ ] 保持 mindmap 只是编辑器块视图,不成为正文事实源。 +- 后续:新增 `editor_runtime/mindmap_node_view.rs`。 +- 后续:迁出 mindmap block 的 node view、runtime mount、projection refresh、simplemindmap event bridge。 +- 后续:保持 mindmap 只是编辑器块视图,不成为正文事实源。 ### P6:收尾 -- [ ] `src/lib.rs` 只保留: +- 后续:`src/lib.rs` 只保留: - module imports - top-level component装配 - 少量常量和 view glue -- [ ] 单个模块超过约 800 行时继续拆分。 -- [ ] 运行 CodeGraph sync/index,确认新增 Rust 模块被索引。 -- [ ] 更新 worker skill/task template:编辑器 bug 默认先查 `editor_runtime/*`,再回到 `lib.rs`。 +- 后续:单个模块超过约 800 行时继续拆分。 +- 已完成:运行 CodeGraph sync/index,确认新增 Rust 模块被索引。 +- 后续:更新 worker skill/task template;编辑器 bug 默认先查 `editor_runtime/*`,再回到 `lib.rs`。 ## 8. History 安全规则 @@ -437,6 +439,37 @@ Reasonix read-only audit: - 块手柄拖拽重排和复制副本仍使用 HTML 整文替换,继续登记为后续 history-safe command 切片;本设计 done gate 只要求至少一个已登记用户编辑路径完成迁移。 +### 10.6 后续 P4 只读审计补充(2026-05-25) + +Reasonix read-only audit: + +- run id: `reasonix-2026-05-24T16-25-22-430Z-3a9a1a82` +- worktree: `/mnt/Data1T/mnote-worktrees/5-28-dom-bridge-audit` +- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T16-25-22-430Z-3a9a1a82/process-handoff.md` +- 类型:只读审计,主控已读取 Hindsight recall 与 `process-handoff.md`。 + +可采纳结论: + +- 后续 P4 第一刀应优先新增 `editor_runtime/dom_selection.rs`,而不是直接抽 `block_menu.rs`。 +- 第一刀候选函数: + - `active_editor_text_selection` + - `selection_summary` + - `has_active_text_selection` + - `selection_has_rich_marks` + - `selection_payload` / selection event payload 构造 + - `send_selection_state` / `send_selection_state_to_target` +- `block_menu.rs` 暂不先做:当前 block menu / overlay 相关函数仍强依赖大量 `ReadSignal` / `WriteSignal`,在合并 overlay state 或先迁出 `HoveredBlockState` 前,直接抽模块只会把长参数耦合搬到新文件。 +- `overlays.rs` 可作为 `dom_selection.rs` 之后的第二刀;`apply_html_update` 仍等 `bridge_events.rs` / overlay state 收口后再迁。 + +建议验证: + +- `cargo fmt --manifest-path rust/Cargo.toml --all --check` +- `cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml` +- `cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release` +- `wasm-bindgen rust/spikes/leptos-tiptap-spike/target/wasm32-unknown-unknown/release/mnote_leptos_tiptap_spike.wasm --target web --out-dir rust/spikes/leptos-tiptap-spike/generated/island --out-name mnote-leptos-tiptap-spike-island` +- `node scripts/task488-local-attachment-link-delete-undo-smoke.js` +- `node scripts/task489-block-menu-delete-undo-smoke.js` + ## 11. 测试与验收 ### Rust 检查 diff --git a/rust/crates/mnote-web/browser/filetree-selection-runtime.js b/rust/crates/mnote-web/browser/filetree-selection-runtime.js new file mode 100644 index 00000000..a83eb54d --- /dev/null +++ b/rust/crates/mnote-web/browser/filetree-selection-runtime.js @@ -0,0 +1,110 @@ +// MNote 主壳文件树选择运行时外置模块。 +// 当前只承接 selection 状态同步和选择算法;事件监听、DND 执行和命令分发仍留在主壳。 + +function cssEscapeValue(value) { + if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value); + return String(value).replace(/["\\]/g, '\\$&'); +} + +function visibleFileTreeRows(deps) { + if (deps && typeof deps.visibleFileTreeRows === 'function') { + return deps.visibleFileTreeRows(); + } + return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) + .filter(function(row) { + if (!(row instanceof HTMLElement)) return false; + if (row.closest('.tree-children--collapsed')) return false; + return row.offsetParent !== null || row.getClientRects().length > 0; + }); +} + +function selectedRowIdSet(selection) { + var selected = selection && selection.selectedRowIds; + if (selected instanceof Set) return new Set(selected); + if (Array.isArray(selected)) return new Set(selected); + return new Set(); +} + +function syncSidebarFileTreeSelection(selection, deps) { + var state = selection || {}; + var selected = selectedRowIdSet(state); + var focusedRowId = String(state.focusedRowId || ''); + visibleFileTreeRows(deps).forEach(function(row) { + var rowId = row.getAttribute('data-row-id') || ''; + row.setAttribute('data-selected', String(Boolean(rowId && selected.has(rowId)))); + row.setAttribute('data-focused', String(rowId === focusedRowId)); + }); + var detail = { + selectedRowIds: Array.from(selected), + anchorRowId: state.anchorRowId || null, + focusedRowId: state.focusedRowId || null + }; + window.dispatchEvent(new CustomEvent('tree.filetree.selection.changed', { detail: detail })); + return detail; +} + +function selectSidebarFileTreeRow(row, selection, modifiers, deps) { + if (!(row instanceof HTMLElement)) return []; + var rowId = row.getAttribute('data-row-id') || ''; + if (!rowId) return []; + var state = selection || {}; + var rows = visibleFileTreeRows(deps); + var visibleRowIds = rows.map(function(item) { return item.getAttribute('data-row-id') || ''; }).filter(Boolean); + var selected = selectedRowIdSet(state); + var shiftKey = Boolean(modifiers && modifiers.shiftKey); + var ctrlKey = Boolean(modifiers && (modifiers.ctrlKey || modifiers.metaKey)); + if (shiftKey && state.anchorRowId) { + var anchorIndex = visibleRowIds.indexOf(state.anchorRowId); + var targetIndex = visibleRowIds.indexOf(rowId); + if (anchorIndex >= 0 && targetIndex >= 0) { + selected = new Set(visibleRowIds.slice(Math.min(anchorIndex, targetIndex), Math.max(anchorIndex, targetIndex) + 1)); + } else { + selected = new Set([rowId]); + state.anchorRowId = rowId; + } + } else if (ctrlKey) { + if (selected.has(rowId) && selected.size > 1) selected.delete(rowId); + else selected.add(rowId); + state.anchorRowId = rowId; + } else { + selected = new Set([rowId]); + state.anchorRowId = rowId; + } + state.selectedRowIds = selected; + state.focusedRowId = rowId; + syncSidebarFileTreeSelection(state, deps); + return Array.from(selected); +} + +function selectedSidebarFileTreeRowIdsForDrag(row, selection) { + var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : ''; + var selected = selectedRowIdSet(selection || {}); + if (rowId && selected.has(rowId)) return Array.from(selected); + return rowId ? [rowId] : []; +} + +function selectedSidebarFileTreeRows(selection, deps) { + var state = selection || {}; + var selectedIds = selectedRowIdSet(state); + var rows = visibleFileTreeRows(deps).filter(function(row) { + var rowId = row.getAttribute('data-row-id') || ''; + return rowId && selectedIds.has(rowId); + }); + if (rows.length > 0) return rows; + if (state.focusedRowId) { + var escape = deps && typeof deps.cssEscape === 'function' ? deps.cssEscape : cssEscapeValue; + var focused = document.querySelector( + '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + escape(state.focusedRowId) + '"]' + ); + if (focused instanceof HTMLElement) return [focused]; + } + return []; +} + +window.__mnoteFileTreeSelectionRuntime = { + visibleFileTreeRows: visibleFileTreeRows, + syncSidebarFileTreeSelection: syncSidebarFileTreeSelection, + selectSidebarFileTreeRow: selectSidebarFileTreeRow, + selectedSidebarFileTreeRowIdsForDrag: selectedSidebarFileTreeRowIdsForDrag, + selectedSidebarFileTreeRows: selectedSidebarFileTreeRows +}; diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 0703f8d4..ad7edcda 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -106,6 +106,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/filetree-runtime.js", get(web_shell::filetree_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/filetree-selection-runtime.js", + get(web_shell::filetree_selection_runtime_asset), + ) .route( "/api/mnote-browser-runtime/filetree-context-menu-runtime.js", get(web_shell::filetree_context_menu_runtime_asset), @@ -522,6 +526,7 @@ mod tests { "/api/mnote-browser-runtime/resource-open-runtime.js", "/api/mnote-browser-runtime/local-upload-runtime.js", "/api/mnote-browser-runtime/filetree-runtime.js", + "/api/mnote-browser-runtime/filetree-selection-runtime.js", "/api/mnote-browser-runtime/filetree-context-menu-runtime.js", "/api/mnote-browser-runtime/tree-live-controller.js", "/api/mnote-browser-runtime/tree-shell-runtime.js", diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 2ccaa7e3..4577cf17 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -4423,6 +4423,20 @@ pub async fn filetree_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn filetree_selection_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/filetree-selection-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn tree_live_controller_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-live-controller.js"); Response::builder() diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index f85c2668..bee5c9eb 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -3162,6 +3162,18 @@ const SIDEBAR_TREE_JS: &str = r##" }; } + function fileTreeSelectionRuntimeFunction(name) { + var runtime = window.__mnoteFileTreeSelectionRuntime; + var fn = runtime && runtime[name]; + return typeof fn === 'function' ? fn : null; + } + + function fileTreeSelectionRuntimeDeps() { + return { + cssEscape: cssEscape + }; + } + function uploadedAssetTitle(asset) { var runtimeFn = localUploadRuntimeFunction('uploadedAssetTitle'); if (runtimeFn) return runtimeFn(asset); @@ -4946,6 +4958,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function visibleFileTreeRows() { + var runtimeFn = fileTreeSelectionRuntimeFunction('visibleFileTreeRows'); + if (runtimeFn) return runtimeFn(fileTreeSelectionRuntimeDeps()); return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) .filter(function(row) { if (!(row instanceof HTMLElement)) return false; @@ -4955,6 +4969,11 @@ const SIDEBAR_TREE_JS: &str = r##" } function syncSidebarFileTreeSelection() { + var runtimeFn = fileTreeSelectionRuntimeFunction('syncSidebarFileTreeSelection'); + if (runtimeFn) { + runtimeFn(sidebarFileTreeSelection, fileTreeSelectionRuntimeDeps()); + return; + } var rows = visibleFileTreeRows(); rows.forEach(function(row) { var rowId = row.getAttribute('data-row-id') || ''; @@ -4971,6 +4990,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function selectSidebarFileTreeRow(row, modifiers) { + var runtimeFn = fileTreeSelectionRuntimeFunction('selectSidebarFileTreeRow'); + if (runtimeFn) return runtimeFn(row, sidebarFileTreeSelection, modifiers, fileTreeSelectionRuntimeDeps()); if (!(row instanceof HTMLElement)) return []; var rowId = row.getAttribute('data-row-id') || ''; if (!rowId) return []; @@ -5110,6 +5131,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function selectedSidebarFileTreeRowIdsForDrag(row) { + var runtimeFn = fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRowIdsForDrag'); + if (runtimeFn) return runtimeFn(row, sidebarFileTreeSelection); var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : ''; if (rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId)) { return Array.from(sidebarFileTreeSelection.selectedRowIds); @@ -5118,6 +5141,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function selectedSidebarFileTreeRows() { + var runtimeFn = fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRows'); + if (runtimeFn) return runtimeFn(sidebarFileTreeSelection, fileTreeSelectionRuntimeDeps()); var selectedIds = sidebarFileTreeSelection.selectedRowIds; var rows = visibleFileTreeRows().filter(function(row) { var rowId = row.getAttribute('data-row-id') || ''; @@ -10538,6 +10563,7 @@ pub fn PageLayout( + @@ -10582,6 +10608,8 @@ mod tests { const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str = include_str!("../../../browser/filetree-context-menu-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"); const TREE_LIVE_CONTROLLER_JS: &str = include_str!("../../../browser/tree-live-controller.js"); fn js_function_body(source: &str, name: &str) -> String { @@ -10937,6 +10965,40 @@ mod tests { assert!(FILETREE_RUNTIME_JS.contains("function fileTreeChildCount")); } + #[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 + .contains("fileTreeSelectionRuntimeFunction('syncSidebarFileTreeSelection')")); + assert!(SIDEBAR_TREE_JS + .contains("fileTreeSelectionRuntimeFunction('selectSidebarFileTreeRow')")); + assert!(SIDEBAR_TREE_JS + .contains("fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRowIdsForDrag')")); + assert!(SIDEBAR_TREE_JS + .contains("fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRows')")); + let select_body = js_function_body(SIDEBAR_TREE_JS, "selectSidebarFileTreeRow"); + assert!( + select_body.contains("shiftKey && sidebarFileTreeSelection.anchorRowId") + && select_body.contains("ctrlKey"), + "inline fallback 必须保留 shift range 与 ctrl/meta toggle" + ); + } + + #[test] + fn filetree_selection_runtime_contains_selection_helpers() { + assert!(FILETREE_SELECTION_RUNTIME_JS.contains("window.__mnoteFileTreeSelectionRuntime")); + assert!(FILETREE_SELECTION_RUNTIME_JS.contains("function visibleFileTreeRows")); + assert!(FILETREE_SELECTION_RUNTIME_JS.contains("function syncSidebarFileTreeSelection")); + assert!(FILETREE_SELECTION_RUNTIME_JS.contains("function selectSidebarFileTreeRow")); + assert!( + FILETREE_SELECTION_RUNTIME_JS.contains("function selectedSidebarFileTreeRowIdsForDrag") + ); + assert!(FILETREE_SELECTION_RUNTIME_JS.contains("function selectedSidebarFileTreeRows")); + assert!(FILETREE_SELECTION_RUNTIME_JS.contains("tree.filetree.selection.changed")); + } + #[test] fn sidebar_tree_runtime_focuses_restored_local_folder_row() { assert!(SIDEBAR_TREE_JS.contains("applyPendingLocalFolderRestoreFocusOnce"));