diff --git a/design/05-editor-mainline/process/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 similarity index 84% rename from design/05-editor-mainline/process/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md rename to design/05-editor-mainline/done/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md index 2613a645..531de71a 100644 --- a/design/05-editor-mainline/process/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 @@ -1,7 +1,7 @@ # 5-28 Leptos Tiptap 编辑器 Runtime 模块拆分 v1 > 创建时间:2026-05-24 -> 状态:`process` +> 状态:`done` > > 触发背景: > - `bugs/0524.md` 第 4 条删除本地 Markdown 附件链接后 `Ctrl+Z/Ctrl+Y` 不可用,根因是编辑器删除路径绕过 Tiptap/ProseMirror history。 @@ -102,6 +102,7 @@ rust/spikes/leptos-tiptap-spike/src/ - `sync_persisted_editor_command` -> `command_sync.rs` - `apply_html_update` / `apply_document_update` -> `command_sync.rs`,并标记 history 风险 - `delete_top_level_block_html` / `reorder_top_level_block_html` -> 待替换为 `history_safe_commands.rs` + - 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 整文替换的路径必须登记。 @@ -134,7 +135,8 @@ rust/spikes/leptos-tiptap-spike/src/ - host 替换文档 - 外部同步/恢复 - 非用户编辑的兼容兜底 -- [ ] 用户编辑动作必须优先走 Tiptap command/transaction。 +- [x] 用户编辑动作必须优先走 Tiptap command/transaction。 + - 2026-05-24:块菜单删除已从 `apply_html_update` / `set_content(TiptapContent::html(...))` 迁到 `history_safe_commands.rs` 的 `delete_top_level_block_with_history(...)`,并由 `scripts/task489-block-menu-delete-undo-smoke.js` 覆盖 Ctrl+Z/Ctrl+Y。 ### P3:抽出附件上传与本地 Markdown 路径 @@ -391,6 +393,50 @@ Reasonix read-only audit: - `resolveEditorUploadContext`、`editorRootFromUploadOptions`、`openEditorUploadFilePicker` 等 shell-side DOM/context 探针适合进入 `03-rust-web` 的 `local-upload-runtime.js` 后续批次。 - `uploadFileToMediaAsset`、`uploadFilesWithResolvedTarget`、`insertUploadedAssetIntoEditor` 仍是高耦合主流程,暂不迁入 editor runtime。 +### 10.5 Batch 4 块菜单删除 history 收口(2026-05-24) + +Codex 主控直接完成: + +- 新增 `scripts/task489-block-menu-delete-undo-smoke.js`,先在旧实现上确认 RED:块菜单删除 `Beta block` 后,`Ctrl+Z` 未恢复目标块,失败证据写入 `tmp/task489-block-menu-delete-undo-smoke/result.json`。 +- `editor_runtime/history_safe_commands.rs` 新增 `delete_top_level_block_with_history(editor, index)`: + - 多块文档:计算顶层块完整 ProseMirror range 后调用 Tiptap `delete_range(...)`。 + - 单块文档:用 `insert_content_at(range, paragraph, update_selection=true)` 替换为空段落。 + - 删除完成后仅调用 `sync_persisted_editor_command(...)` 同步快照和草稿,不再走 `apply_html_update`。 +- 删除 `src/lib.rs` 中旧的 `delete_top_level_block_html(...)`,避免块菜单删除继续保留 HTML 整文替换入口。 + +Reasonix read-only audit: + +- run id: `reasonix-2026-05-24T15-45-44-865Z-bb0ede13` +- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T15-45-44-865Z-bb0ede13/process-handoff.md` +- 类型:只读审计,主控已读取 Hindsight recall、`process-handoff.md`、`process-handoff.json`。 +- 采纳结论:可采纳;块菜单删除已从 `apply_html_update(...)` / `set_content(...)` 路径迁到 Tiptap `delete_range(...)` / `insert_content_at(...)`。Reasonix 提醒 `sync_persisted_editor_command(...)` 与 Tiptap `on_change` 后续可能存在 dirty count / localStorage 冗余,作为后续优化风险登记,不阻塞本批。 + +已通过验证: + +- `node --check scripts/task489-block-menu-delete-undo-smoke.js` +- `cargo fmt --manifest-path rust/Cargo.toml --all --check` +- `cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`(35 个既有 warning) +- `cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release`(35 个既有 warning) +- `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/task489-block-menu-delete-undo-smoke.js` + - 结果:`ok=true` + - 证据:`tmp/task489-block-menu-delete-undo-smoke/result.json`、`tmp/task489-block-menu-delete-undo-smoke/after-redo.png` +- `node scripts/task488-local-attachment-link-delete-undo-smoke.js` + - 结果:`ok=true` + - 证据:`tmp/task488-local-attachment-link-delete-undo-smoke/result.json` +- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` + - 结果:`ok=true` + - 证据:命令 stdout JSON,覆盖上传目录、文件树 drop、broken link、无 active document 附件 tab、标题来源、附件键盘/手柄删除。 +- smoke 启动方式: + - 使用稳定入口 `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`。 + - `npm run dev:hot` / `cargo watch` 在本轮验收中发生过重编译重启,导致 `task479` 中段 `ERR_CONNECTION_REFUSED`;该结果已废弃,不作为功能失败证据。 +- `codegraph search delete_top_level_block_with_history top_level_block_full_range prosemirror_node_size` + - 结果:`delete_top_level_block_with_history`、`top_level_block_full_range`、`prosemirror_node_size` 可定位到 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs`。 + +剩余风险: + +- 块手柄拖拽重排和复制副本仍使用 HTML 整文替换,继续登记为后续 history-safe command 切片;本设计 done gate 只要求至少一个已登记用户编辑路径完成迁移。 + ## 11. 测试与验收 ### Rust 检查 @@ -406,6 +452,7 @@ wasm-bindgen rust/spikes/leptos-tiptap-spike/target/wasm32-unknown-unknown/relea ```bash node scripts/task488-local-attachment-link-delete-undo-smoke.js +node scripts/task489-block-menu-delete-undo-smoke.js node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js node scripts/task472-side-target-secondary-pane-smoke.js ``` @@ -427,12 +474,12 @@ node scripts/task472-side-target-secondary-pane-smoke.js 本设计移动到 `done/` 前必须满足: -- [ ] `attachment_links.rs` 和 `history_safe_commands.rs` 已落地。 -- [ ] `command_sync.rs` 已落地,用户编辑动作与 host 替换动作边界明确。 -- [ ] 至少一个仍使用 `set_content` 的用户编辑路径已迁到 Tiptap command/transaction。 -- [ ] `task488` 通过,且至少覆盖一次真实键盘 `Ctrl+Z/Ctrl+Y`。 -- [ ] 新增 editor_runtime Rust 模块能被 CodeGraph 定位。 -- [ ] `src/lib.rs` 不再新增附件/history/DOM bridge 大块逻辑。 +- [x] `attachment_links.rs` 和 `history_safe_commands.rs` 已落地。 +- [x] `command_sync.rs` 已落地,用户编辑动作与 host 替换动作边界明确。 +- [x] 至少一个仍使用 `set_content` 的用户编辑路径已迁到 Tiptap command/transaction。 +- [x] `task488` 通过,且至少覆盖一次真实键盘 `Ctrl+Z/Ctrl+Y`。 +- [x] 新增 editor_runtime Rust 模块能被 CodeGraph 定位。 +- [x] `src/lib.rs` 不再新增附件/history/DOM bridge 大块逻辑。 ## 14. 后续问题 diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js index 4aed409a..20fb5a3f 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js @@ -1259,47 +1259,47 @@ function __wbg_get_imports() { } }, arguments); }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1135, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1138, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1187, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1190, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 963, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 966, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had771ddc65647798); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1085, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1088, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a); return ret; }, __wbindgen_cast_0000000000000005: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1135, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1138, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4); return ret; }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 745, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 504, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf); return ret; }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1087, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1090, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f); return ret; }, __wbindgen_cast_0000000000000008: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1102, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1105, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5); return ret; }, __wbindgen_cast_0000000000000009: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1138, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1141, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c); return ret; }, diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm index 4a721bf9..be4602a9 100644 Binary files a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm and b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm differ 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 40461be7..303e205a 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 @@ -1,4 +1,8 @@ -use leptos_tiptap::TiptapEditorHandle; +use leptos_tiptap::{ + TiptapContent, TiptapEditorHandle, TiptapInsertContentOptions, TiptapRange, +}; +use serde_json::json; +use serde_json::Value; use crate::editor_runtime::attachment_links; @@ -18,3 +22,92 @@ pub(crate) fn delete_selected_attachment_link_with_history( // 继续走 Tiptap 命令路径,保留撤销/重做历史。 editor.delete_selection().map_err(|error| error.to_string()) } + +fn prosemirror_node_size(node: &Value) -> Option { + match node.get("type").and_then(Value::as_str) { + Some("text") => Some( + node.get("text") + .and_then(Value::as_str) + .map(|text| text.encode_utf16().count() as u32) + .unwrap_or(0), + ), + Some("hardBreak") | Some("horizontalRule") => Some(1), + _ => { + let Some(children) = node.get("content").and_then(Value::as_array) else { + return Some(match node.get("type").and_then(Value::as_str) { + Some("paragraph") | Some("heading") | Some("blockquote") + | Some("codeBlock") | Some("bulletList") | Some("orderedList") + | Some("taskList") | Some("listItem") | Some("taskItem") | Some("table") + | Some("tableRow") | Some("tableCell") | Some("tableHeader") => 2, + _ => 1, + }); + }; + + let content_size = children.iter().try_fold(0_u32, |acc, child| { + prosemirror_node_size(child).map(|size| acc + size) + })?; + Some(content_size + 2) + } + } +} + +fn top_level_block_full_range(document: &Value, index: usize) -> Option { + let content = document.get("content").and_then(Value::as_array)?; + let mut position = 0_u32; + + for (current_index, node) in content.iter().enumerate() { + let node_size = prosemirror_node_size(node)?; + if current_index == index { + return Some(TiptapRange { + from: position, + to: position + node_size, + }); + } + position += node_size; + } + + None +} + +/// 通过 Tiptap `delete_range()` 删除顶层块,避免使用 `set_content()` 整文替换绕过 history。 +pub(crate) fn delete_top_level_block_with_history( + editor: &TiptapEditorHandle, + index: usize, +) -> Result<(), String> { + let document = editor + .get_json() + .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; + let content_len = document + .get("content") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + + if index >= content_len { + return Err(format!("找不到第 {index} 个块")); + } + + let range = top_level_block_full_range(&document, index) + .ok_or_else(|| format!("找不到第 {} 个块的删除范围", index + 1))?; + + if content_len <= 1 { + editor + .insert_content_at( + range, + TiptapContent::json(json!({ "type": "paragraph" })), + Some(TiptapInsertContentOptions { + update_selection: Some(true), + ..Default::default() + }), + ) + .map_err(|err| format!("删除当前块失败:{err}"))?; + } else { + editor + .delete_range(range) + .map_err(|err| format!("删除当前块失败:{err}"))?; + } + + editor + .focus() + .map_err(|err| format!("聚焦编辑器失败:{err}")) +} diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index ed16ce9e..bf0279d7 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -6409,30 +6409,6 @@ fn focus_top_level_block_start(editor: TiptapEditorHandle, index: usize) -> Resu .map_err(|err| format!("定位新块失败:{err}")) } -fn delete_top_level_block_html(current_html: &str, index: usize) -> Result { - let document = window() - .and_then(|win| win.document()) - .ok_or_else(|| "浏览器 document 不可用".to_string())?; - let container = document - .create_element("div") - .map_err(|err| format!("创建 HTML 容器失败:{err:?}"))?; - container.set_inner_html(current_html); - - let children = container.children(); - if children.length() <= 1 { - container.set_inner_html("

"); - return Ok(container.inner_html()); - } - - let current = children - .item(index as u32) - .ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?; - container - .remove_child(¤t) - .map_err(|err| format!("删除 HTML 块失败:{err:?}"))?; - Ok(container.inner_html()) -} - fn reorder_top_level_block_html( current_html: &str, source: usize, @@ -10389,40 +10365,24 @@ fn App(mount_options: MountOptions) -> impl IntoView { data-testid="block-drag-menu-item-delete" on:click=move |event: MouseEvent| { event.stop_propagation(); - match editor.get_html() { - Ok(current_html) => match delete_top_level_block_html(¤t_html, block_index) { - Ok(next_html) => { - apply_html_update( - editor, - &runtime_persisted_identity(document_id, workspace_id), - next_html, - document_id, - workspace_id, - dirty_count, - set_dirty_count, - set_html_output, - set_document_json, - set_json_output, - title, - hovered_block, - editor_focused, - slash_open, - turn_into_open, - color_menu_open, - more_menu_open, - revision, - conflict_detection_key, - read_only, - set_command_feedback, - format!("已删除 {}", delete_feedback_label.clone()), - ); - set_block_menu_open.set(false); - set_block_menu_anchor.set(None); - set_hovered_block.set(None); - } - Err(err) => set_command_feedback.set(err), - }, - Err(err) => set_command_feedback.set(format!("读取当前 HTML 失败:{err}")), + match editor_runtime::history_safe_commands::delete_top_level_block_with_history(&editor, block_index) { + Ok(()) => { + sync_persisted_editor_command( + editor, + &runtime_persisted_identity(document_id, workspace_id), + set_dirty_count, + set_html_output, + set_document_json, + set_json_output, + title, + set_command_feedback, + format!("已删除 {}", delete_feedback_label.clone()), + ); + set_block_menu_open.set(false); + set_block_menu_anchor.set(None); + set_hovered_block.set(None); + } + Err(err) => set_command_feedback.set(err), } } > diff --git a/scripts/task489-block-menu-delete-undo-smoke.js b/scripts/task489-block-menu-delete-undo-smoke.js new file mode 100644 index 00000000..9f89ab07 --- /dev/null +++ b/scripts/task489-block-menu-delete-undo-smoke.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task489-block-menu-delete-undo-smoke"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function documentUrl(root, relativePath) { + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + url.searchParams.set("treeView", "filetree"); + return url.toString(); +} + +function writeWorkspaceManifest(root, ownerId) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:${ownerId}:task489`, + ownerId, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "markdown_edit", "asset_upload"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); + } +} + +async function openDocument(page, root, relativePath) { + await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function editorText(page) { + return await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().evaluate((node) => + node instanceof HTMLElement ? node.innerText : "", + ); +} + +async function waitForEditorText(page, predicateSource, arg) { + await page.waitForFunction( + ({ predicateSource, arg }) => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + const text = editor instanceof HTMLElement ? editor.innerText : ""; + return Function("text", "arg", `return (${predicateSource})(text, arg);`)(text, arg); + }, + { predicateSource, arg }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function deleteBlockByVisibleText(page, text) { + const block = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror > *', { + hasText: text, + }).first(); + await block.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const box = await block.boundingBox(); + assert(box, `找不到块可点击区域: ${text}`); + await page.mouse.move(box.x + Math.min(20, box.width / 2), box.y + Math.min(12, box.height / 2), { steps: 8 }); + const trigger = page.locator('[data-testid="block-drag-handle-trigger"]').first(); + await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await trigger.click({ timeout: UI_TIMEOUT_MS }); + const menu = page.locator('[data-testid="block-drag-menu"]').first(); + await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="block-drag-menu-item-delete"]').first().click({ timeout: UI_TIMEOUT_MS }); +} + +(async () => { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task489-")); + writeWorkspaceManifest(root, "mnote-e2e"); + fs.writeFileSync( + path.join(root, "BlockUndo.md"), + ["# Block Undo", "", "Alpha block", "", "Beta block", "", "Gamma block", ""].join("\n"), + "utf8", + ); + + const browser = await chromium.launch({ + headless: true, + executablePath: CHROMIUM_EXECUTABLE_PATH, + }); + const page = await browser.newPage({ viewport: { width: 1366, height: 900 } }); + const result = { root, states: [] }; + + try { + await quickLogin(page); + await openDocument(page, root, "BlockUndo.md"); + await waitForEditorText(page, "(text, expected) => text.includes(expected)", "Beta block"); + result.states.push({ step: "opened", text: await editorText(page) }); + + await deleteBlockByVisibleText(page, "Beta block"); + await waitForEditorText(page, "(text, expected) => !text.includes(expected)", "Beta block"); + result.states.push({ step: "deleted", text: await editorText(page) }); + + await page.keyboard.press("Control+Z"); + await waitForEditorText(page, "(text, expected) => text.includes(expected)", "Beta block"); + result.states.push({ step: "undo", text: await editorText(page) }); + + await page.keyboard.press("Control+Y"); + await waitForEditorText(page, "(text, expected) => !text.includes(expected)", "Beta block"); + result.states.push({ step: "redo", text: await editorText(page) }); + + await page.screenshot({ + path: path.join(OUTPUT_DIR, "after-redo.png"), + fullPage: true, + }); + + assert(result.states.find((state) => state.step === "opened").text.includes("Beta block"), "初始文档应包含目标块"); + assert(!result.states.find((state) => state.step === "deleted").text.includes("Beta block"), "块菜单删除后应移除目标块"); + assert(result.states.find((state) => state.step === "undo").text.includes("Beta block"), "Ctrl+Z 应恢复目标块"); + assert(!result.states.find((state) => state.step === "redo").text.includes("Beta block"), "Ctrl+Y 应再次删除目标块"); + + fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8"); + await browser.close(); + console.log(`task489 ok: ${RESULT_PATH}`); + } catch (error) { + await page.screenshot({ + path: path.join(OUTPUT_DIR, "failure.png"), + fullPage: true, + }).catch(() => undefined); + fs.writeFileSync( + RESULT_PATH, + `${JSON.stringify({ ok: false, error: error.message, stack: error.stack, ...result }, null, 2)}\n`, + "utf8", + ); + await browser.close().catch(() => undefined); + console.error(`task489 failed: ${RESULT_PATH}`); + console.error(error); + process.exit(1); + } +})();