refactor: move block delete to history-safe commands
This commit is contained in:
+55
-8
@@ -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. 后续问题
|
||||
|
||||
+9
-9
@@ -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;
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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(¤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),
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user