refactor: prune block transform helpers

This commit is contained in:
lix-2026
2026-05-25 06:27:00 +08:00
parent 8bcf958fe8
commit 0509081bbb
6 changed files with 85 additions and 212 deletions
@@ -6,7 +6,7 @@
## 1. 目标
`3-19` 已归档的基础上,继续把仍留在 Rust inline / 主壳大 runtime 中的浏览器行为切成可审计模块。Codex 维护本文件作为事实源,Reasonix 只承接短批次审计、窄范围实现或浏览器 smoke;当前 goal 下每批最多 2 个 worker。
`3-19` 已归档的基础上,继续把仍留在 Rust inline / 主壳大 runtime 中的浏览器行为切成可审计模块。Codex 维护本文件作为事实源,Reasonix 只承接短批次审计、窄范围实现或浏览器 smoke;当前 goal 下每批最多 4 个 worker。
## 2. 本轮边界
@@ -172,3 +172,7 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 2026-05-25Codex 独立复核并采纳 Batch Q Worker A 遗留候选 patch`local_folder_events.rs``treeLive=true` 且 watcher subscribe 失败时不再返回 500,而是返回同一 SSE 响应并降级为 revision polling;初始仍发送 `snapshot`revision 变化后通过既有 `rebuild_tree_resync_payload` 发送 `resync`。客户端 `tree:snapshot` / `tree:resync` consumer 不改,避免叠加 browser double-polling。该 patch 来自无 handoff 的失控 worker,未按 Reasonix 结果直接采纳,而是由 Codex 本地阅读 diff、跑验证后作为主控修复纳入。
- 修改:`rust/crates/mnote-web/src/routes/local_folder_events.rs`
- 已验证:`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 tree_live_polling_resync_payload_tracks_revision_changes -- --test-threads=1``cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_events -- --test-threads=1``git diff --check``node --check scripts/task487-local-folder-tree-live-consumer-smoke.js`、临时 `MNOTE_WEB_BIND=127.0.0.1:3002 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3002 cargo run --manifest-path rust/Cargo.toml -p mnote-web` 后运行 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3002 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task487-local-folder-tree-live-consumer-smoke.js`,结果 `ok:true`PageTree/FileTree 外部创建/删除均为 `treeLiveApplied=resync`,两个阶段 `navigationEvents=0`
- 2026-05-25Batch Z 曾一次派发 4 个 Reasonix worker,超过当前 goal 的“每批最多 2 个 worker”;其中 Worker C 负责 3-20 filetree refresh helperWorker D 负责 3-20 tree live smoke;四个 run 均未产出 `final.md``result.json``process-handoff.md/json`worktree diff 为空,全部拒收。
- 涉及 run`reasonix-2026-05-24T22-02-32-495Z-dfd6bb89``reasonix-2026-05-24T22-02-32-511Z-ea50d6e0``reasonix-2026-05-24T22-02-32-635Z-4edc4f8a``reasonix-2026-05-24T22-02-32-644Z-1180a395`
- 额外异常:Reasonix 仍创建 `.codex/reasonix-tasks/2026-05-25-batch-x-worker-a-5-29-dnd-dom-readonly.md` 草稿,违反叶子 worker 约束;该草稿不提交。
- 本批未采纳任何 3-20 diff。主工作区仅为 5-29 block transform 候选 patch 补跑 `task479-local-folder-markdown-resource-lifecycle-smoke.js` 作为资源生命周期回归,结果 `ok:true`,截图 `/tmp/mnote-task479-lifecycle-RJDsKO/screenshots-1779660971673/final-state.png`
@@ -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;当前 goal 下每批最多 2 个 worker。
`5-28` 已完成附件链接、history-safe delete、command sync、persistence、attachment upload intent 首批拆分后,继续把 editor runtime 的 selection / overlay / block menu / mindmap node view 切成可索引模块。Codex 维护本文件作为事实源,Reasonix 只做短批次审计、窄范围实现或浏览器 smoke;当前 goal 下每批最多 4 个 worker。
## 2. 本轮边界
@@ -217,3 +217,10 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 未迁:`target_element` 仍被图片 toolbar 和事件 listener 共用;`hovered_block_from_target``drop_indicator_from_target``drop_indicator_from_point`、拖拽 listener、block reorder 行为和 DOM -> block 状态解析链仍留在 `lib.rs`
- 修改:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs``rust/spikes/leptos-tiptap-spike/src/lib.rs`、generated island `js/wasm`
- 已验证:`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``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task489-block-menu-delete-undo-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task488-local-attachment-link-delete-undo-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`
- 2026-05-25Batch Z 曾一次派发 4 个 Reasonix worker,超过当前 goal 的“每批最多 2 个 worker”;四个 worker 均使用独立 worktreeA 只读审计 block transform、B 实现 block transform、C 实现 3-20 filetree refresh helper、D 运行 3-20 live smoke。
- Reasonix run `reasonix-2026-05-24T22-02-32-495Z-dfd6bb89` / `reasonix-2026-05-24T22-02-32-511Z-ea50d6e0` / `reasonix-2026-05-24T22-02-32-635Z-4edc4f8a` / `reasonix-2026-05-24T22-02-32-644Z-1180a395` 均只有 `prompt.md``memory-recall.json``reasonix-transcript.jsonl`,无 `final.md``result.json``process-handoff.md/json`;四个 worktree diff 为空,按采纳规则全部拒收。
- 额外异常:Reasonix 仍创建 `.codex/reasonix-tasks/2026-05-25-batch-x-worker-a-5-29-dnd-dom-readonly.md` 草稿,违反叶子 worker 约束;该草稿不纳入本文件事实源、不提交。
- Codex 随后在独立 worktree `/mnt/Data1T/mnote-worktrees/0525-x-worker-b-5-29-block-transform-impl` 产出并复核候选 patch:把仍有真实调用方的 `paragraph_node``collect_plain_text``mindmap_paragraph_node` 迁入 `editor_runtime/block_menu_document.rs`,删除 `lib.rs` 中无调用方的旧 JSON transform 死链 `list_item_node``task_item_node``inline_from_first_child``extract_inline_content``turn_into_block``replace_top_level_block_kind` 及图片占位常量。
- 修改:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_menu_document.rs``rust/spikes/leptos-tiptap-spike/src/lib.rs`
- 已验证:候选 worktree中 `git diff --check``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` 通过;主工作区复跑 `git diff --check``cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task489-block-menu-delete-undo-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task488-local-attachment-link-delete-undo-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` 通过。
- 未完成:`block_menu_document.rs``duplicate_top_level_block``delete_top_level_block``reorder_top_level_block` 仍显示无 runtime 调用;是否删除需另起只读审计确认历史测试 / future intent,不在本批扩大。
@@ -1270,7 +1270,7 @@ function __wbg_get_imports() {
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h19d53c3ed5a90a91);
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had771ddc65647798);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
@@ -1284,8 +1284,8 @@ 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: 799, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha181c94e3b9968a5);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 892, 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) {
@@ -1359,8 +1359,8 @@ function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h19d53c3ed5a90a91(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h19d53c3ed5a90a91(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__had771ddc65647798(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__had771ddc65647798(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) {
@@ -1371,8 +1371,8 @@ function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, a
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__ha181c94e3b9968a5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__ha181c94e3b9968a5(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) {
@@ -1,6 +1,6 @@
//! block menu 使用的顶层块 JSON helper。
use serde_json::{json, Value};
use serde_json::{json, Map, Value};
use crate::editor_runtime::block_hover_state::DropPlacement;
@@ -65,6 +65,57 @@ pub(crate) fn reorder_top_level_block(
Ok(())
}
fn node_with_attrs(
type_name: &str,
attrs: Option<Map<String, Value>>,
content: Vec<Value>,
) -> Value {
let mut node = Map::new();
node.insert("type".to_string(), Value::String(type_name.to_string()));
if let Some(attrs) = attrs.filter(|attrs| !attrs.is_empty()) {
node.insert("attrs".to_string(), Value::Object(attrs));
}
if !content.is_empty() {
node.insert("content".to_string(), Value::Array(content));
}
Value::Object(node)
}
pub(crate) fn paragraph_node(content: Vec<Value>) -> Value {
node_with_attrs("paragraph", None, content)
}
pub(crate) fn collect_plain_text(node: &Value) -> String {
match node.get("type").and_then(Value::as_str) {
Some("text") => node
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
Some("hardBreak") => "\n".to_string(),
_ => node
.get("content")
.and_then(Value::as_array)
.map(|children| {
children
.iter()
.map(collect_plain_text)
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default(),
}
}
pub(crate) fn mindmap_paragraph_node(next_mindmap_id: impl FnOnce() -> String) -> Value {
let mut attrs = Map::new();
attrs.insert("mnoteBlockType".to_string(), json!("mindmap"));
attrs.insert("mindmapId".to_string(), json!(next_mindmap_id()));
attrs.insert("rootNodeId".to_string(), json!("root"));
attrs.insert("projectionVersion".to_string(), json!(1));
node_with_attrs("paragraph", Some(attrs), Vec::new())
}
#[cfg(test)]
mod tests {
use serde_json::json;
+13 -202
View File
@@ -11,7 +11,7 @@ use leptos_tiptap::{
};
use send_wrapper::SendWrapper;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use serde_json::{json, Value};
use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display};
use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
use wasm_bindgen_futures::{spawn_local, JsFuture};
@@ -27,6 +27,9 @@ use editor_runtime::block_dnd::{
HandleCorridorGeometry,
};
use editor_runtime::block_menu_overlay;
use editor_runtime::block_menu_document::{
collect_plain_text, mindmap_paragraph_node, paragraph_node,
};
use editor_runtime::block_menu_legacy_html::{
duplicate_top_level_block_html, reorder_top_level_block_html,
};
@@ -81,10 +84,6 @@ const MINDMAP_SHELL_MINIMAP_EVENT: &str = "mnote:mindmap-shell:minimap";
const MINDMAP_SHELL_ZOOM_EVENT: &str = "mnote:mindmap-shell:zoom";
const MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT: &str = "mnote:mindmap-shell:toolbar-overflow";
const STANDALONE_ROOT_ID: &str = "mnote-leptos-tiptap-standalone-root";
const E24_IMAGE_PLACEHOLDER_SRC: &str = "/api/editor/image-placeholder.svg";
const E24_IMAGE_PLACEHOLDER_ALT: &str = "E24 图片占位";
const E24_IMAGE_PLACEHOLDER_TITLE: &str = "E24 图片";
mod editor_runtime;
#[wasm_bindgen(inline_js = r#"
@@ -5261,26 +5260,6 @@ fn document_content_mut(document: &mut Value) -> Result<&mut Vec<Value>, String>
.ok_or_else(|| "文档 JSON 缺少顶层 content 数组".to_string())
}
fn node_with_attrs(
type_name: &str,
attrs: Option<Map<String, Value>>,
content: Vec<Value>,
) -> Value {
let mut node = Map::new();
node.insert("type".to_string(), Value::String(type_name.to_string()));
if let Some(attrs) = attrs.filter(|attrs| !attrs.is_empty()) {
node.insert("attrs".to_string(), Value::Object(attrs));
}
if !content.is_empty() {
node.insert("content".to_string(), Value::Array(content));
}
Value::Object(node)
}
fn paragraph_node(content: Vec<Value>) -> Value {
node_with_attrs("paragraph", None, content)
}
fn next_mindmap_id() -> String {
let now = js_sys::Date::new_0();
format!(
@@ -5291,181 +5270,6 @@ fn next_mindmap_id() -> String {
)
}
fn mindmap_paragraph_node() -> Value {
let mut attrs = Map::new();
attrs.insert("mnoteBlockType".to_string(), json!("mindmap"));
attrs.insert("mindmapId".to_string(), json!(next_mindmap_id()));
attrs.insert("rootNodeId".to_string(), json!("root"));
attrs.insert("projectionVersion".to_string(), json!(1));
node_with_attrs("paragraph", Some(attrs), Vec::new())
}
fn list_item_node(content: Vec<Value>) -> Value {
node_with_attrs("listItem", None, vec![paragraph_node(content)])
}
fn task_item_node(content: Vec<Value>) -> Value {
let mut attrs = Map::new();
attrs.insert("checked".to_string(), Value::Bool(false));
node_with_attrs("taskItem", Some(attrs), vec![paragraph_node(content)])
}
fn collect_plain_text(node: &Value) -> String {
match node.get("type").and_then(Value::as_str) {
Some("text") => node
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
Some("hardBreak") => "\n".to_string(),
_ => node
.get("content")
.and_then(Value::as_array)
.map(|children| {
children
.iter()
.map(collect_plain_text)
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default(),
}
}
fn inline_from_first_child(children: &[Value]) -> Vec<Value> {
children
.first()
.map(extract_inline_content)
.unwrap_or_default()
}
fn extract_inline_content(node: &Value) -> Vec<Value> {
match node.get("type").and_then(Value::as_str) {
Some("paragraph") | Some("heading") => node
.get("content")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default(),
Some("blockquote") => node
.get("content")
.and_then(Value::as_array)
.map(|children| inline_from_first_child(children))
.unwrap_or_default(),
Some("bulletList") | Some("orderedList") | Some("taskList") => node
.get("content")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("content"))
.and_then(Value::as_array)
.map(|children| inline_from_first_child(children))
.unwrap_or_default(),
Some("codeBlock") => {
let text = collect_plain_text(node);
if text.is_empty() {
Vec::new()
} else {
vec![json!({"type": "text", "text": text})]
}
}
Some("horizontalRule") => Vec::new(),
_ => node
.get("content")
.and_then(Value::as_array)
.map(|children| {
children
.iter()
.flat_map(extract_inline_content)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
}
}
fn turn_into_block(node: &Value, action: SlashActionKind) -> Value {
let inline = extract_inline_content(node);
match action {
SlashActionKind::AiAssistant
| SlashActionKind::AiWrite
| SlashActionKind::ContinueWriting
| SlashActionKind::Summarize
| SlashActionKind::MoreAi => paragraph_node(inline.clone()),
SlashActionKind::Paragraph => paragraph_node(inline),
SlashActionKind::Heading1 => {
let mut attrs = Map::new();
attrs.insert("level".to_string(), json!(1));
attrs.insert("collapsed".to_string(), Value::Null);
node_with_attrs("heading", Some(attrs), inline)
}
SlashActionKind::Heading2 => {
let mut attrs = Map::new();
attrs.insert("level".to_string(), json!(2));
attrs.insert("collapsed".to_string(), Value::Null);
node_with_attrs("heading", Some(attrs), inline)
}
SlashActionKind::Heading3 => {
let mut attrs = Map::new();
attrs.insert("level".to_string(), json!(3));
attrs.insert("collapsed".to_string(), Value::Null);
node_with_attrs("heading", Some(attrs), inline)
}
SlashActionKind::Heading4 => {
let mut attrs = Map::new();
attrs.insert("level".to_string(), json!(4));
attrs.insert("collapsed".to_string(), Value::Null);
node_with_attrs("heading", Some(attrs), inline)
}
SlashActionKind::BulletList => {
node_with_attrs("bulletList", None, vec![list_item_node(inline)])
}
SlashActionKind::OrderedList => {
node_with_attrs("orderedList", None, vec![list_item_node(inline)])
}
SlashActionKind::Todo | SlashActionKind::AdvancedTodo => {
node_with_attrs("taskList", None, vec![task_item_node(inline)])
}
SlashActionKind::Quote => node_with_attrs("blockquote", None, vec![paragraph_node(inline)]),
SlashActionKind::CodeBlock => {
let text = collect_plain_text(node);
let mut attrs = Map::new();
attrs.insert("language".to_string(), Value::String("rust".to_string()));
let content = if text.is_empty() {
Vec::new()
} else {
vec![json!({"type": "text", "text": text})]
};
node_with_attrs("codeBlock", Some(attrs), content)
}
SlashActionKind::Divider => node_with_attrs("horizontalRule", None, Vec::new()),
SlashActionKind::SimpleTable | SlashActionKind::UploadAttachment | SlashActionKind::Toc => {
paragraph_node(inline)
}
SlashActionKind::Mindmap => mindmap_paragraph_node(),
SlashActionKind::Image => {
let mut attrs = Map::new();
attrs.insert("src".to_string(), json!(E24_IMAGE_PLACEHOLDER_SRC));
attrs.insert("alt".to_string(), json!(E24_IMAGE_PLACEHOLDER_ALT));
attrs.insert("title".to_string(), json!(E24_IMAGE_PLACEHOLDER_TITLE));
node_with_attrs("image", Some(attrs), Vec::new())
}
}
}
fn replace_top_level_block_kind(
document: &mut Value,
index: usize,
action: SlashActionKind,
) -> Result<(), String> {
let content = document_content_mut(document)?;
let next = turn_into_block(
content
.get(index)
.ok_or_else(|| format!("找不到第 {index} 个块"))?,
action,
);
content[index] = next;
Ok(())
}
fn apply_html_update(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
@@ -6185,7 +5989,10 @@ fn run_slash_action(
editor.insert_table(4, 3, false)
}
SlashActionKind::Mindmap => {
editor.insert_content(TiptapContent::json(mindmap_paragraph_node()), None)
editor.insert_content(
TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)),
None,
)
}
SlashActionKind::Image => {
dispatch_editor_upload_request("image", "image/*")?;
@@ -6272,7 +6079,11 @@ fn run_block_turn_into_action(
.map(|_| "已把当前块转成分割线")
.map_err(|err| format!("命令执行失败:{err}")),
SlashActionKind::Mindmap => editor
.insert_content_at(range, TiptapContent::json(mindmap_paragraph_node()), None)
.insert_content_at(
range,
TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)),
None,
)
.map(|_| "已把当前块转成思维导图")
.map_err(|err| format!("命令执行失败:{err}")),
_ => {