fix: restore block menu delete undo
This commit is contained in:
@@ -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 测试。
|
||||
@@ -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 与验证证据后再选择性合入。
|
||||
|
||||
BIN
Binary file not shown.
@@ -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<Value>, 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"]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user