feat: EditorRuntimeActor - 三层缓存/delta/事件架构

Phase A — EditorRuntimeActor 内存缓存层
- 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init
- block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径
- editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启)
- bridge-runtime 三个核心函数公开化
- rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞)

Phase B — 编辑器增量 delta channel
- BlockDelta/DeltaOperation 类型 + actor.build_block_delta()
- leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch
- DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent
- 工具响应含 blockDelta 字段供前端消费

Phase C — 事件 stream delta
- broadcast channel 在 AppState/actor/SSE 三层贯通
- tree_events SSE 端点发 block.delta 事件
- 旧客户端降级兼容

环境修复
- rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出)
- run-convex-deploy.js(封装 Convex function 部署到本地后端 3210)

ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md
This commit is contained in:
lix-2026
2026-05-16 22:03:30 +08:00
parent d8bfaea306
commit f292c6710a
101 changed files with 13618 additions and 2416 deletions
+205
View File
@@ -42,6 +42,7 @@ const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state";
const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status";
const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection";
const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command";
const BLOCK_DELTA_EVENT: &str = "mnote:editor:block-delta";
const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height";
const MINDMAP_SHELL_ACTION_EVENT: &str = "mnote:mindmap-shell:action";
const MINDMAP_SHELL_PANEL_EVENT: &str = "mnote:mindmap-shell:panel";
@@ -8245,6 +8246,64 @@ fn App(mount_options: MountOptions) -> impl IntoView {
register_runtime_listener(mount_id, target, command_listener);
}
}
// ── Block-delta listenerPhase BAI 写入增量通道) ──
let delta_editor = editor;
let delta_set_document_json = set_document_json;
let delta_json_output = json_output;
let delta_set_json_output = set_json_output;
let delta_set_dirty_count = set_dirty_count;
let delta_set_html_output = set_html_output;
let delta_listener =
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
return;
};
let detail = custom_event.detail();
let Ok(delta): Result<Value, _> =
serde_wasm_bindgen::from_value(detail)
else {
return;
};
let Some(operations) = delta.get("operations").and_then(Value::as_array) else {
return;
};
if operations.is_empty() {
return;
}
// Editor instance maybe unavailable while loading
let Some(instance) = delta_editor.instance_untracked() else {
return;
};
// Read current content
let Ok(mut content) = instance.get_json() else {
return;
};
// Apply delta operations to the Tiptap JSON tree
let changed = apply_block_delta_to_json(&mut content, operations);
if !changed {
return;
}
// Write back
if instance.set_content(TiptapContent::json(content.clone())).is_ok() {
// Update reactive state
let html = instance.get_html().unwrap_or_default();
let json_text = serde_json::to_string(&content).unwrap_or_default();
delta_set_dirty_count.update(|c| *c += 1);
delta_set_html_output.set(html);
delta_set_json_output.set(json_text);
delta_set_document_json.set(content);
}
}));
if let Some(document) = window().and_then(|win| win.document()) {
let delta_ref = delta_listener.as_ref().unchecked_ref();
let _ = document.add_event_listener_with_callback(BLOCK_DELTA_EVENT, delta_ref);
if let Some((mount_id, _, _)) = runtime_mount_context() {
let target: EventTarget = document.into();
register_runtime_listener(mount_id, target, delta_listener);
}
}
}
});
@@ -11099,3 +11158,149 @@ pub fn standalone_main() {
view! { <App mount_options=MountOptions::default()/> }
});
}
// ── Phase BBlock Delta apply ──────────────────────────────
/// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。
/// 返回 true 表示树发生了变更。
fn apply_block_delta_to_json(content: &mut Value, operations: &[Value]) -> bool {
let Some(content_arr) = content.get_mut("content").and_then(Value::as_array_mut) else {
return false;
};
let mut changed = false;
for op_value in operations {
let Some(op) = op_value.get("op").and_then(Value::as_str) else {
continue;
};
match op {
"replace" => {
let block_id = op_value.get("block_id").and_then(Value::as_str);
let text = op_value.get("text").and_then(Value::as_str).unwrap_or("");
let block_type = op_value.get("block_type").and_then(Value::as_str);
if let Some(bid) = block_id {
if apply_replace_block(content_arr, bid, text, block_type) {
changed = true;
}
}
}
"insert_after" => {
let anchor = op_value.get("anchor_block_id").and_then(Value::as_str);
let block_id = op_value.get("block_id").and_then(Value::as_str);
let text = op_value.get("text").and_then(Value::as_str).unwrap_or("");
if let (Some(aid), Some(bid)) = (anchor, block_id) {
if apply_insert_block_after(content_arr, aid, bid, text) {
changed = true;
}
}
}
"delete" => {
let block_id = op_value.get("block_id").and_then(Value::as_str);
if let Some(bid) = block_id {
if apply_delete_block(content_arr, bid) {
changed = true;
}
}
}
"move_after" => {
let block_id = op_value.get("block_id").and_then(Value::as_str);
let anchor = op_value.get("anchor_block_id").and_then(Value::as_str);
if let (Some(bid), Some(aid)) = (block_id, anchor) {
if apply_move_block_after(content_arr, bid, aid) {
changed = true;
}
}
}
_ => {}
}
}
changed
}
fn find_block_index(blocks: &[Value], block_id: &str) -> Option<usize> {
blocks.iter().position(|b| {
b.get("attrs")
.and_then(|a| a.get("block_id"))
.and_then(Value::as_str)
== Some(block_id)
})
}
fn apply_replace_block(
blocks: &mut Vec<Value>,
block_id: &str,
text: &str,
block_type: Option<&str>,
) -> bool {
let Some(idx) = find_block_index(blocks, block_id) else { return false; };
let block = &mut blocks[idx];
// 更新 block type
if let Some(bt) = block_type {
if let Some(b) = block.as_object_mut() {
b.insert("type".into(), json!(bt));
}
}
// 更新 text content
let new_content: Value = if text.is_empty() {
json!([])
} else {
json!([{ "type": "text", "text": text }])
};
if let Some(b) = block.as_object_mut() {
b.insert("content".into(), new_content);
}
true
}
fn apply_insert_block_after(
blocks: &mut Vec<Value>,
anchor_block_id: &str,
new_block_id: &str,
text: &str,
) -> bool {
let Some(idx) = find_block_index(blocks, anchor_block_id) else { return false; };
let new_block = json!({
"type": "paragraph",
"attrs": { "block_id": new_block_id },
"content": if text.is_empty() {
json!([])
} else {
json!([{ "type": "text", "text": text }])
}
});
blocks.insert(idx + 1, new_block);
true
}
fn apply_delete_block(blocks: &mut Vec<Value>, block_id: &str) -> bool {
let Some(idx) = find_block_index(blocks, block_id) else { return false; };
blocks.remove(idx);
true
}
fn apply_move_block_after(
blocks: &mut Vec<Value>,
block_id: &str,
anchor_block_id: &str,
) -> bool {
let Some(block_idx) = find_block_index(blocks, block_id) else { return false; };
let Some(anchor_idx) = find_block_index(blocks, anchor_block_id) else { return false; };
// Can't move to itself or anchor after block
if block_idx == anchor_idx || block_idx == anchor_idx + 1 {
return false;
}
let block = blocks.remove(block_idx);
// After removal, anchor may have shifted if block was before anchor
let adjusted_anchor = if block_idx < anchor_idx {
anchor_idx - 1
} else {
anchor_idx
};
blocks.insert(adjusted_anchor + 1, block);
true
}