From 018eb6ccc293a9447c6c9658628ebdcba76e9d91 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Mon, 25 May 2026 01:45:03 +0800 Subject: [PATCH] fix: restore block menu delete undo --- ...39-block-menu-delete-undo-regression-v1.md | 57 +++++++++ ...29-editor-runtime-followup-checklist-v1.md | 12 +- .../mnote-leptos-tiptap-spike-island_bg.wasm | Bin 2107149 -> 2107174 bytes .../src/editor_runtime/block_menu_document.rs | 112 ++++++++++++++++++ .../src/editor_runtime/mod.rs | 1 + rust/spikes/leptos-tiptap-spike/src/lib.rs | 55 +-------- 6 files changed, 182 insertions(+), 55 deletions(-) create mode 100644 bugs/05-editor-mainline/done/5-39-block-menu-delete-undo-regression-v1.md create mode 100644 rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_menu_document.rs diff --git a/bugs/05-editor-mainline/done/5-39-block-menu-delete-undo-regression-v1.md b/bugs/05-editor-mainline/done/5-39-block-menu-delete-undo-regression-v1.md new file mode 100644 index 00000000..9e04613d --- /dev/null +++ b/bugs/05-editor-mainline/done/5-39-block-menu-delete-undo-regression-v1.md @@ -0,0 +1,57 @@ +# 5-39 Block menu 删除后 Ctrl+Z 不恢复目标块 + +> 创建时间:2026-05-25 +> 状态:`done` +> Owner:`05-editor-mainline` + +## 现象 + +`node scripts/task489-block-menu-delete-undo-smoke.js` 连续两次失败。块菜单删除 `Beta block` 成功,但随后 `Control+Z` 未恢复目标块,最终等待 `Beta block` 重新出现超时。 + +失败证据: + +- `tmp/task489-block-menu-delete-undo-smoke/result.json` +- `tmp/task489-block-menu-delete-undo-smoke/failure.png` + +当前 result 摘要: + +```json +{ + "ok": false, + "states": [ + { "step": "opened", "text": "Block Undo\n\nAlpha block\n\nBeta block\n\nGamma block" }, + { "step": "deleted", "text": "Block Undo\n\nAlpha block\n\nGamma block" } + ], + "error": "page.waitForFunction: Timeout 30000ms exceeded." +} +``` + +## 初步判断 + +失败点不是删除动作本身,而是删除后的 undo 历史不可恢复。当前代码已经调用 `editor_runtime::history_safe_commands::delete_top_level_block_with_history(&editor, block_index)`,需要继续核实: + +- `delete_range(range)` 是否真的进入 Tiptap history。 +- 删除后 `sync_persisted_editor_command(...)` 是否触发了会清空 undo history 或重建 editor state 的路径。 +- 失败是否与焦点丢失有关;需要用最小浏览器复现确认点击 editor 后再 `Control+Z` 是否能恢复。 + +## 根因 + +块菜单按钮点击后,删除命令先通过 Tiptap history-safe `delete_range` 路径删除目标块,并在 helper 内调用了一次 `editor.focus()`。但随后 Leptos 继续关闭 block menu / anchor / hovered block 状态,点击目标按钮被卸载,最终浏览器焦点可能落回非 ProseMirror 元素。`Control+Z` 因此没有稳定送到 Tiptap editor,表现为删除成功但撤销不恢复。 + +## 修复 + +- 在块菜单删除成功、关闭菜单状态后,再补一次 `editor.focus()`,确保后续键盘撤销进入 ProseMirror。 +- 同批拆出 `editor_runtime/block_menu_document.rs`,隔离 legacy 顶层块 JSON helper;当前运行路径仍优先使用 history-safe command,不回退到 HTML 整文替换删除。 + +## 验收 + +- `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` 更新 island wasm。 +- `node scripts/task489-block-menu-delete-undo-smoke.js` 通过。 +- `node scripts/task488-local-attachment-link-delete-undo-smoke.js` 通过。 +- 不得回退到 HTML 整文替换删除路径。 + +## 未覆盖 + +- `cargo test --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml block_menu_document -- --test-threads=1` 当前被既有 `STYLE` 测试编译错误阻断,错误为 `cannot find value STYLE in this scope`,未进入本次新增 helper 测试。 diff --git a/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md b/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md index 5686dada..e82f842a 100644 --- a/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md +++ b/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md @@ -6,7 +6,7 @@ ## 1. 目标 -在 `5-28` 已完成附件链接、history-safe delete、command sync、persistence、attachment upload intent 首批拆分后,继续把 editor runtime 的 selection / overlay / block menu / mindmap node view 切成可索引模块。Codex 维护本文件作为事实源,Reasonix 只做短批次审计、窄范围实现或浏览器 smoke。 +在 `5-28` 已完成附件链接、history-safe delete、command sync、persistence、attachment upload intent 首批拆分后,继续把 editor runtime 的 selection / overlay / block menu / mindmap node view 切成可索引模块。Codex 维护本文件作为事实源,Reasonix 只做短批次审计、窄范围实现或浏览器 smoke;每批最多 2 个 worker。 ## 2. 本轮边界 @@ -80,3 +80,13 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据 - Codex 合入时保持实现逻辑同构,仅收口模块边界和 checklist 事实记录。 - 已验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`、wasm release build、`wasm-bindgen`、`node scripts/task488-local-attachment-link-delete-undo-smoke.js`、`node scripts/task489-block-menu-delete-undo-smoke.js`。 - 未完成:HTML 整文替换仍是 legacy 路径,后续要用 history-safe Tiptap command 替换;`sync_editor_overlay_state`、block menu signal 仍未拆出。 +- 2026-05-25:复跑浏览器 smoke 后修正上一条验收口径:`task488` 通过,但 `task489-block-menu-delete-undo-smoke` 连续两次失败,失败点是块菜单删除后 `Control+Z` 未恢复 `Beta block`。 + - 证据:`tmp/task489-block-menu-delete-undo-smoke/result.json`、`tmp/task489-block-menu-delete-undo-smoke/failure.png`。 + - 已登记缺陷:`bugs/05-editor-mainline/done/5-39-block-menu-delete-undo-regression-v1.md`。 + - Worker E(`reasonix-2026-05-24T17-34-41-774Z-85427058`)已在独立 worktree `/mnt/Data1T/mnote-worktrees/0525-b3-block-delete-undo` 派发修复任务,但 runner 仅产出 `prompt.md` / `memory-recall.json` / `reasonix-transcript.jsonl`,无 `final.md`、`result.json`、`process-handoff.md/json`,worktree 无 diff,拒绝采纳。 + - 当前结论:5-29 仍未完成;下一步应先定位 `delete_top_level_block_with_history`、`sync_persisted_editor_command`、editor focus / undo keydown 之间的真实断点,再修复。 +- 2026-05-25:Codex 主控修复 `5-39`:块菜单删除成功并关闭菜单状态后追加一次 `editor.focus()`,确保 `Control+Z` 进入 ProseMirror;缺陷文档已移到 `bugs/05-editor-mainline/done/5-39-block-menu-delete-undo-regression-v1.md`。 + - 同批新增 `editor_runtime/block_menu_document.rs`,迁出未走当前运行路径的 legacy 顶层块 JSON helper:`duplicate_top_level_block`、`delete_top_level_block`、`reorder_top_level_block`;`replace_top_level_block_kind` 暂不迁,因仍依赖 slash action、mindmap 和 image placeholder 语义。 + - 已验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`、wasm release build、`wasm-bindgen`、`node scripts/task489-block-menu-delete-undo-smoke.js`、`node scripts/task488-local-attachment-link-delete-undo-smoke.js`。 + - 未覆盖:`cargo test --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml block_menu_document -- --test-threads=1` 被既有 `STYLE` 测试编译错误阻断,未进入本次新增 helper 测试。 +- 2026-05-25:根据用户纠正,后续 B/C/D 等实现 worker 不再只读或在主工作区直接修改;Batch C 统一采用独立 worktree 候选 patch,Codex 读取 Hindsight recall、`process-handoff.md/json`、`result.json`、diff 与验证证据后再选择性合入。 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 43e68b1cfafa0c509149fbcd929a79e9d6238d86..9d85cfaa0c9cdd36ad4f0626577d2d32a421dea9 100644 GIT binary patch delta 8640 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 diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_menu_document.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_menu_document.rs new file mode 100644 index 00000000..555ba051 --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_menu_document.rs @@ -0,0 +1,112 @@ +//! block menu 使用的顶层块 JSON helper。 + +use serde_json::{json, Value}; + +use crate::DropPlacement; + +fn document_content_mut(document: &mut Value) -> Result<&mut Vec, String> { + document + .get_mut("content") + .and_then(Value::as_array_mut) + .ok_or_else(|| "文档 JSON 缺少顶层 content 数组".to_string()) +} + +pub(crate) fn duplicate_top_level_block(document: &mut Value, index: usize) -> Result<(), String> { + let content = document_content_mut(document)?; + let block = content + .get(index) + .cloned() + .ok_or_else(|| format!("找不到第 {index} 个块"))?; + content.insert(index + 1, block); + Ok(()) +} + +pub(crate) fn delete_top_level_block(document: &mut Value, index: usize) -> Result<(), String> { + let content = document_content_mut(document)?; + if index >= content.len() { + return Err(format!("找不到第 {index} 个块")); + } + + if content.len() == 1 { + content[0] = json!({"type": "paragraph"}); + return Ok(()); + } + + content.remove(index); + Ok(()) +} + +pub(crate) fn reorder_top_level_block( + document: &mut Value, + source: usize, + target: usize, + placement: DropPlacement, +) -> Result<(), String> { + let content = document_content_mut(document)?; + if source >= content.len() || target >= content.len() { + return Err("拖拽目标超出当前顶层块范围".to_string()); + } + + let block = content.remove(source); + let mut insert_at = match placement { + DropPlacement::Before => target, + DropPlacement::After => target + 1, + }; + + if source < insert_at { + insert_at = insert_at.saturating_sub(1); + } + + if insert_at > content.len() { + insert_at = content.len(); + } + + content.insert(insert_at, block); + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn delete_only_top_level_block_keeps_empty_paragraph() { + let mut document = json!({ + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "A"}]} + ] + }); + + delete_top_level_block(&mut document, 0).expect("delete block"); + + assert_eq!( + document, + json!({"type": "doc", "content": [{"type": "paragraph"}]}) + ); + } + + #[test] + fn reorder_top_level_block_adjusts_target_after_source_removal() { + let mut document = json!({ + "type": "doc", + "content": [ + {"type": "paragraph", "attrs": {"id": "a"}}, + {"type": "paragraph", "attrs": {"id": "b"}}, + {"type": "paragraph", "attrs": {"id": "c"}} + ] + }); + + reorder_top_level_block(&mut document, 0, 2, DropPlacement::After).expect("reorder block"); + + let ids: Vec<_> = document["content"] + .as_array() + .expect("content") + .iter() + .map(|node| node["attrs"]["id"].as_str().unwrap_or_default()) + .collect(); + assert_eq!(ids, vec!["b", "c", "a"]); + } +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs index 4c07f717..caffebd5 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod attachment_links; pub(crate) mod attachment_upload; +pub(crate) mod block_menu_document; pub(crate) mod block_menu_legacy_html; pub(crate) mod command_sync; pub(crate) mod dom_selection; diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index a7730bcf..4628bc9c 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -5748,60 +5748,6 @@ fn turn_into_block(node: &Value, action: SlashActionKind) -> Value { } } -fn duplicate_top_level_block(document: &mut Value, index: usize) -> Result<(), String> { - let content = document_content_mut(document)?; - let block = content - .get(index) - .cloned() - .ok_or_else(|| format!("找不到第 {index} 个块"))?; - content.insert(index + 1, block); - Ok(()) -} - -fn delete_top_level_block(document: &mut Value, index: usize) -> Result<(), String> { - let content = document_content_mut(document)?; - if index >= content.len() { - return Err(format!("找不到第 {index} 个块")); - } - - if content.len() == 1 { - content[0] = paragraph_node(Vec::new()); - return Ok(()); - } - - content.remove(index); - Ok(()) -} - -fn reorder_top_level_block( - document: &mut Value, - source: usize, - target: usize, - placement: DropPlacement, -) -> Result<(), String> { - let content = document_content_mut(document)?; - if source >= content.len() || target >= content.len() { - return Err("拖拽目标超出当前顶层块范围".to_string()); - } - - let block = content.remove(source); - let mut insert_at = match placement { - DropPlacement::Before => target, - DropPlacement::After => target + 1, - }; - - if source < insert_at { - insert_at = insert_at.saturating_sub(1); - } - - if insert_at > content.len() { - insert_at = content.len(); - } - - content.insert(insert_at, block); - Ok(()) -} - fn replace_top_level_block_kind( document: &mut Value, index: usize, @@ -10164,6 +10110,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { set_block_menu_open.set(false); set_block_menu_anchor.set(None); set_hovered_block.set(None); + let _ = editor.focus(); } Err(err) => set_command_feedback.set(err), }