diff --git a/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md b/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md
index 09a32880..e994ece9 100644
--- a/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md
+++ b/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md
@@ -63,7 +63,7 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
## 6. 本轮执行记录
- 2026-05-25:Codex 创建本续跑事实源。
-- 2026-05-25:修正执行约束:本文件遵循当前 goal 的“每批最多 2 个 worker”,不再沿用早期草稿中的 4 worker 并行方案。
+- 2026-05-25:修正执行约束:本文件遵循当前 goal 的“每批最多 2 个 worker”;实现 worker 必须在独立 worktree 产出候选 patch,Codex 复核后再选择性合入。
- 2026-05-25:下一批只派发 Worker B(local upload / filetree 下一刀只读审计);Worker A/C/D 暂缓。
- 2026-05-25:Reasonix local upload / filetree 只读审计尝试未产生 `result.json` / `process-handoff`,且运行期间出现额外 Batch A2 runner,主控已终止进程;该结果不作为正式 handoff 采纳。
- 可参考的 transcript 结论:`fetchWithTimeout` 是低风险纯工具函数,可先迁入 `local-upload-runtime.js`;`uploadFileToMediaAsset` / `uploadFilesWithResolvedTarget` / `insertUploadedAssetIntoEditor` 仍与 API、editor 插入和 snapshot refresh 耦合,暂不迁。
@@ -79,3 +79,7 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 修改:`rust/crates/mnote-web/browser/document-conflict-panel-runtime.js`、`rust/crates/mnote-web/src/routes/web_shell.rs`、`rust/crates/mnote-web/src/routes/mod.rs`。
- 已验证:`node --check rust/crates/mnote-web/browser/document-conflict-panel-runtime.js`、`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/Cargo.toml -p mnote-web`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1`。
- 未完成:conflict merge / accept / keep 业务逻辑、document session lifecycle、secondary pane conflict smoke 仍未迁出。
+- 2026-05-25:Worker D(`insertUploadedAssetIntoEditor` 外置)两次在独立 worktree `/mnt/Data1T/mnote-worktrees/0525-a2-upload-insert-current` 重新派发,均未产出可采纳 diff。
+ - `reasonix-2026-05-24T17-07-59-638Z-29274a46` 产生 handoff,但内容串到 Worker B 的 block menu 任务,worktree 无 diff,拒绝采纳。
+ - `reasonix-2026-05-24T17-12-41-044Z-25b9d5d7` 仅产生 `prompt.md` / `memory-recall.json` / `reasonix-transcript.jsonl`,无 `final.md`、`result.json`、`process-handoff.md/json`,worktree 无 diff,拒绝采纳。
+ - 该项保持未完成;下一刀应进一步缩窄为只迁 editor 插入 DOM 拼接中的一个纯 helper,或由 Codex 本地实现后再交 Reasonix 做浏览器 smoke。
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 36eddbf5..5686dada 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
@@ -75,3 +75,8 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 未迁:`sync_editor_overlay_state` / `try_sync_editor_overlay_state`,原因是仍依赖 `HoveredBlockState`、slash/block menu signal 和 overlay 关闭策略。
- 已验证:`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`。
- 运行中再次出现额外 Batch A2 Reasonix runner,主控已终止;这些 runner 未作为本批证据。
+- 2026-05-25:采纳并复核 Worker B 候选 patch:新增 `editor_runtime/block_menu_legacy_html.rs`,迁出 `duplicate_top_level_block_html`、`reorder_top_level_block_html`;`lib.rs` 仅保留调用点和 `DropPlacement` 的 `pub(crate)` 可见性。
+ - Reasonix run:`reasonix-2026-05-24T17-07-59-603Z-bfe72d77`,已读取 Hindsight recall、`process-handoff.md/json`、diff,并由 Codex 独立复核。
+ - 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 仍未拆出。
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 08abe00c..934140ff 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
@@ -1284,7 +1284,7 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 506, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 713, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf);
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 9c40bd1c..43e68b1c 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/block_menu_legacy_html.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_menu_legacy_html.rs
new file mode 100644
index 00000000..89c1f151
--- /dev/null
+++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_menu_legacy_html.rs
@@ -0,0 +1,120 @@
+//! 基于 HTML 字符串的 block menu legacy helper。
+//!
+//! 这些函数通过浏览器 DOM API 操作编辑器完整 HTML 字符串:
+//! `window.document.createElement("div") + innerHTML 解析 -> DOM 修改 ->
+//! innerHTML 回读`。这是 history-safe Tiptap command 之前的过渡路径,
+//! 新代码应优先使用 command-based mutation,不要继续扩展整文 HTML 替换。
+//!
+//! # History 风险
+//!
+//! HTML 字符串往返会丢失 ProseMirror 内部状态,包括 marks、decorations、
+//! selection 和 undo history。调用方随后必须通过 `apply_html_update` 用原始
+//! HTML 重置编辑器,因此也会破坏撤销历史。本模块只用于隔离待替换的 legacy
+//! helper。
+
+use wasm_bindgen::JsCast;
+use web_sys::{window, Element};
+
+use crate::DropPlacement;
+
+/// 复制第 `index` 个顶层 HTML 块并插入其后。
+///
+/// 纯 DOM 操作:解析 `current_html` 为 `
` 子元素,克隆目标块,
+/// 插入到下一个同级节点前(或末尾),返回序列化 HTML。
+pub(crate) fn duplicate_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();
+ let current = children
+ .item(index as u32)
+ .ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?;
+ let cloned = current
+ .clone_node_with_deep(true)
+ .map_err(|err| format!("复制 HTML 块失败:{err:?}"))?
+ .dyn_into::()
+ .map_err(|_| "复制出的节点不是 Element".to_string())?;
+
+ if let Some(next_sibling) = children.item(index as u32 + 1) {
+ container
+ .insert_before(&cloned, Some(&next_sibling))
+ .map_err(|err| format!("插入复制块失败:{err:?}"))?;
+ } else {
+ container
+ .append_child(&cloned)
+ .map_err(|err| format!("追加复制块失败:{err:?}"))?;
+ }
+
+ Ok(container.inner_html())
+}
+
+/// 在 HTML 层将第 `source` 个顶层块移动到第 `target` 个块的前/后。
+///
+/// 纯 DOM 操作:解析 `current_html`,克隆拖拽块,移除原始块,
+/// 在调整后的位置插入克隆,返回序列化 HTML。
+pub(crate) fn reorder_top_level_block_html(
+ current_html: &str,
+ source: usize,
+ target: usize,
+ placement: DropPlacement,
+) -> 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 initial_children = container.children();
+ let block_count = initial_children.length() as usize;
+ if source >= block_count || target >= block_count {
+ return Err("拖拽目标超出当前顶层块范围".to_string());
+ }
+
+ let current = initial_children
+ .item(source as u32)
+ .ok_or_else(|| format!("找不到第 {source} 个 HTML 顶层块"))?;
+ let moving = current
+ .clone_node_with_deep(true)
+ .map_err(|err| format!("复制拖拽块失败:{err:?}"))?
+ .dyn_into::()
+ .map_err(|_| "复制出的拖拽块不是 Element".to_string())?;
+
+ container
+ .remove_child(¤t)
+ .map_err(|err| format!("移除原始拖拽块失败:{err:?}"))?;
+
+ let mut insert_at = match placement {
+ DropPlacement::Before => target,
+ DropPlacement::After => target + 1,
+ };
+ if source < insert_at {
+ insert_at = insert_at.saturating_sub(1);
+ }
+
+ let current_children = container.children();
+ let current_len = current_children.length() as usize;
+ if insert_at >= current_len {
+ container
+ .append_child(&moving)
+ .map_err(|err| format!("追加拖拽块失败:{err:?}"))?;
+ } else {
+ let next_sibling = current_children
+ .item(insert_at as u32)
+ .ok_or_else(|| format!("找不到第 {insert_at} 个 HTML 插入位置"))?;
+ container
+ .insert_before(&moving, Some(&next_sibling))
+ .map_err(|err| format!("插入拖拽块失败:{err:?}"))?;
+ }
+
+ Ok(container.inner_html())
+}
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 42b3b61c..4c07f717 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_legacy_html;
pub(crate) mod command_sync;
pub(crate) mod dom_selection;
pub(crate) mod history_safe_commands;
diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs
index e9b37a57..a7730bcf 100644
--- a/rust/spikes/leptos-tiptap-spike/src/lib.rs
+++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs
@@ -21,6 +21,9 @@ use web_sys::{
};
use editor_runtime::attachment_upload::dispatch_editor_upload_request;
+use editor_runtime::block_menu_legacy_html::{
+ duplicate_top_level_block_html, reorder_top_level_block_html,
+};
use editor_runtime::command_sync::{
read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command,
};
@@ -4834,7 +4837,7 @@ struct BlockMenuLayout {
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-enum DropPlacement {
+pub(crate) enum DropPlacement {
Before,
After,
}
@@ -6229,38 +6232,6 @@ fn apply_html_update(
}
}
-fn duplicate_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();
- let current = children
- .item(index as u32)
- .ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?;
- let cloned = current
- .clone_node_with_deep(true)
- .map_err(|err| format!("复制 HTML 块失败:{err:?}"))?
- .dyn_into::()
- .map_err(|_| "复制出的节点不是 Element".to_string())?;
-
- if let Some(next_sibling) = children.item(index as u32 + 1) {
- container
- .insert_before(&cloned, Some(&next_sibling))
- .map_err(|err| format!("插入复制块失败:{err:?}"))?;
- } else {
- container
- .append_child(&cloned)
- .map_err(|err| format!("追加复制块失败:{err:?}"))?;
- }
-
- Ok(container.inner_html())
-}
-
fn top_level_block_boundary_position(document: &Value, index: usize, before: bool) -> Option {
let content = document.get("content").and_then(Value::as_array)?;
let mut position = 0_u32;
@@ -6343,65 +6314,6 @@ fn focus_top_level_block_start(editor: TiptapEditorHandle, index: usize) -> Resu
.map_err(|err| format!("定位新块失败:{err}"))
}
-fn reorder_top_level_block_html(
- current_html: &str,
- source: usize,
- target: usize,
- placement: DropPlacement,
-) -> 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 initial_children = container.children();
- let block_count = initial_children.length() as usize;
- if source >= block_count || target >= block_count {
- return Err("拖拽目标超出当前顶层块范围".to_string());
- }
-
- let current = initial_children
- .item(source as u32)
- .ok_or_else(|| format!("找不到第 {source} 个 HTML 顶层块"))?;
- let moving = current
- .clone_node_with_deep(true)
- .map_err(|err| format!("复制拖拽块失败:{err:?}"))?
- .dyn_into::()
- .map_err(|_| "复制出的拖拽块不是 Element".to_string())?;
-
- container
- .remove_child(¤t)
- .map_err(|err| format!("移除原始拖拽块失败:{err:?}"))?;
-
- let mut insert_at = match placement {
- DropPlacement::Before => target,
- DropPlacement::After => target + 1,
- };
- if source < insert_at {
- insert_at = insert_at.saturating_sub(1);
- }
-
- let current_children = container.children();
- let current_len = current_children.length() as usize;
- if insert_at >= current_len {
- container
- .append_child(&moving)
- .map_err(|err| format!("追加拖拽块失败:{err:?}"))?;
- } else {
- let next_sibling = current_children
- .item(insert_at as u32)
- .ok_or_else(|| format!("找不到第 {insert_at} 个 HTML 插入位置"))?;
- container
- .insert_before(&moving, Some(&next_sibling))
- .map_err(|err| format!("插入拖拽块失败:{err:?}"))?;
- }
-
- Ok(container.inner_html())
-}
-
fn p0_extensions() -> Vec {
vec![
TiptapExtension::Document,