refactor: move block delete to history-safe commands

This commit is contained in:
lix-2026
2026-05-24 23:56:25 +08:00
parent 437c690a39
commit f2a7de9896
6 changed files with 343 additions and 76 deletions
@@ -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. 后续问题
@@ -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;
},
@@ -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<u32> {
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<TiptapRange> {
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}"))
}
+18 -58
View File
@@ -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<String, String> {
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("<p></p>");
return Ok(container.inner_html());
}
let current = children
.item(index as u32)
.ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?;
container
.remove_child(&current)
.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(&current_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),
}
}
>
@@ -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);
}
})();