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:
@@ -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 listener(Phase B:AI 写入增量通道) ──
|
||||
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 B:Block 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user