From cf7a57d86a3d142f77ff1511aafe0918a139dce1 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 26 May 2026 03:12:59 +0800 Subject: [PATCH] refactor: split tiptap runtime bridge --- ...ime-module-maintainability-checklist-v1.md | 2 +- .../src/editor_runtime/attachment_links.rs | 13 +- .../src/editor_runtime/block_dnd.rs | 3 +- .../src/editor_runtime/block_transform.rs | 147 ++++++ .../editor_runtime/history_safe_commands.rs | 4 +- .../src/editor_runtime/mindmap_node_view.rs | 15 +- .../src/editor_runtime/mod.rs | 2 + .../src/editor_runtime/overlays.rs | 3 +- .../src/editor_runtime/runtime_bridge.rs | 196 ++++++++ rust/spikes/leptos-tiptap-spike/src/lib.rs | 476 +++--------------- 10 files changed, 444 insertions(+), 417 deletions(-) create mode 100644 rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs create mode 100644 rust/spikes/leptos-tiptap-spike/src/editor_runtime/runtime_bridge.rs diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index f72644df..07295eb9 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -245,7 +245,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tree -- -- - [x] E1. `style.rs`:迁出 `SPIKE_STYLE`,根 `lib.rs` 只引用常量。 - [ ] E2. `mount.rs`:迁出 mount context、mounted handles、`mount_app_into` 周边可独立部分;`#[wasm_bindgen]` wrapper 可暂留根文件。 -- [ ] E3. `runtime_bridge.rs`:迁出 `dispatch_runtime_event`、`dispatch_*_to_target`、host command listener install。 +- [x] E3. `runtime_bridge.rs`:迁出 `dispatch_runtime_event`、`dispatch_*_to_target`、host command listener install。 - [ ] E4. `block_transform.rs`:迁出 `run_block_turn_into_action`、`top_level_block_matches_action`、page/block transform helpers。 - [ ] E5. `slash_menu_view.rs`:迁出 slash menu Leptos view 和 click handling。 - [ ] E6. `block_handle_menu_view.rs`:迁出 block handle menu 和 submenu view,作为最后一刀。 diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs index 55fc3e39..b51ad519 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs @@ -48,8 +48,7 @@ pub(crate) fn selected_local_attachment_link_in_editor_state() -> bool { let Some(root) = editor_root_element() else { return false; }; - let Ok(editor_value) = js_sys::Reflect::get(root.as_ref(), &JsValue::from_str("editor")) - else { + let Ok(editor_value) = js_sys::Reflect::get(root.as_ref(), &JsValue::from_str("editor")) else { return false; }; let Ok(state_value) = js_sys::Reflect::get(&editor_value, &JsValue::from_str("state")) else { @@ -66,12 +65,10 @@ pub(crate) fn selected_local_attachment_link_in_editor_state() -> bool { if is_empty { return false; } - let Some(get_attributes) = js_sys::Reflect::get( - &editor_value, - &JsValue::from_str("getAttributes"), - ) - .ok() - .and_then(|value| value.dyn_into::().ok()) + let Some(get_attributes) = + js_sys::Reflect::get(&editor_value, &JsValue::from_str("getAttributes")) + .ok() + .and_then(|value| value.dyn_into::().ok()) else { return false; }; diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs index 8a17e10c..ba6eb863 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs @@ -4,10 +4,11 @@ //! 也不分发 editor command;调用方负责保留编辑器状态编排。 use crate::{ + editor_root_element, editor_runtime::block_hover_state::{ block_label_for_element, DropIndicatorState, DropPlacement, HoveredBlockState, }, - editor_root_element, editor_stage_element, + editor_stage_element, }; use web_sys::{window, Element, EventTarget}; diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs new file mode 100644 index 00000000..8e71728c --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs @@ -0,0 +1,147 @@ +use serde_json::{json, Value}; + +/// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。 +/// 返回 true 表示树发生了变更。 +pub(crate) 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 { + 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, + 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]; + + if let Some(bt) = block_type { + if let Some(b) = block.as_object_mut() { + b.insert("type".into(), json!(bt)); + } + } + + 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, + 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, 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, 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; + }; + + if block_idx == anchor_idx || block_idx == anchor_idx + 1 { + return false; + } + + let block = blocks.remove(block_idx); + let adjusted_anchor = if block_idx < anchor_idx { + anchor_idx - 1 + } else { + anchor_idx + }; + blocks.insert(adjusted_anchor + 1, block); + true +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs index 8a8f76b7..9ff5ebd6 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs @@ -1,6 +1,4 @@ -use leptos_tiptap::{ - TiptapContent, TiptapEditorHandle, TiptapInsertContentOptions, TiptapRange, -}; +use leptos_tiptap::{TiptapContent, TiptapEditorHandle, TiptapInsertContentOptions, TiptapRange}; use serde_json::json; use serde_json::Value; diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs index 29658791..cf8dc34f 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs @@ -9,9 +9,9 @@ //! //! #[wasm_bindgen] 薄包装器保留在 lib.rs 中。 -use leptos::prelude::*; use leptos::ev; use leptos::mount::mount_to; +use leptos::prelude::*; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use serde_wasm_bindgen; @@ -30,7 +30,8 @@ pub(crate) const MINDMAP_SHELL_ACTION_EVENT: &str = "mnote:mindmap-shell:action" pub(crate) const MINDMAP_SHELL_PANEL_EVENT: &str = "mnote:mindmap-shell:panel"; pub(crate) const MINDMAP_SHELL_MINIMAP_EVENT: &str = "mnote:mindmap-shell:minimap"; pub(crate) const MINDMAP_SHELL_ZOOM_EVENT: &str = "mnote:mindmap-shell:zoom"; -pub(crate) const MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT: &str = "mnote:mindmap-shell:toolbar-overflow"; +pub(crate) const MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT: &str = + "mnote:mindmap-shell:toolbar-overflow"; pub(crate) struct MountedMindmapShell { // 保留 Leptos mount handle,避免 mindmap shell 被提前释放。 #[allow(dead_code)] @@ -372,7 +373,10 @@ fn render_mindmap_sidebar_option( } #[component] -pub(crate) fn MindmapShell(options: MindmapShellOptions, event_target: EventTarget) -> impl IntoView { +pub(crate) fn MindmapShell( + options: MindmapShellOptions, + event_target: EventTarget, +) -> impl IntoView { let toolbar_groups = options.toolbar_groups.clone(); let sidebar_panels = options.sidebar_panels.clone(); let navigator = options.navigator.clone(); @@ -873,7 +877,10 @@ fn take_mindmap_shell_handle(id: u32) -> Option { MOUNTED_MINDMAP_SHELLS.with(|registry| registry.borrow_mut().remove(&id)) } -pub(crate) fn mount_mindmap_shell_impl(container: JsValue, options: JsValue) -> Result { +pub(crate) fn mount_mindmap_shell_impl( + container: JsValue, + options: JsValue, +) -> Result { console_error_panic_hook::set_once(); let options = serde_wasm_bindgen::from_value::(options)?; let target = container 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 fabc9e90..f644afe9 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod block_dnd; pub(crate) mod block_hover_state; pub(crate) mod block_menu_document; pub(crate) mod block_menu_overlay; +pub(crate) mod block_transform; pub(crate) mod bridge_dispatch; pub(crate) mod bridge_events; pub(crate) mod command_sync; @@ -15,6 +16,7 @@ pub(crate) mod history_safe_commands; pub(crate) mod mindmap_node_view; pub(crate) mod overlays; pub(crate) mod persistence; +pub(crate) mod runtime_bridge; pub(crate) mod slash_actions; pub(crate) mod style; pub(crate) mod table_commands; diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs index 8bf62f74..edc7c6a6 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs @@ -216,8 +216,7 @@ pub(crate) fn clamp_overlay_anchor( gap: f64, ) -> (f64, f64) { let (viewport_width, viewport_height) = viewport_dimensions(); - let clamped_left = - raw_left.clamp(gap, (viewport_width - overlay_width - gap).max(gap)); + let clamped_left = raw_left.clamp(gap, (viewport_width - overlay_width - gap).max(gap)); let below_limit = viewport_height - gap; let clamped_top = if raw_top + overlay_height > below_limit { (raw_anchor_top - overlay_height - gap).clamp(gap, below_limit - 120.0) diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/runtime_bridge.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/runtime_bridge.rs new file mode 100644 index 00000000..b1bdf620 --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/runtime_bridge.rs @@ -0,0 +1,196 @@ +//! 编辑器 runtime 与宿主之间的事件桥接 helper。 +//! +//! 本模块只承接 envelope dispatch、listener registry 和低耦合 host command +//! listener 安装逻辑;信号读写和编辑器命令处理仍留在 `lib.rs`。 +use std::{any::Any, collections::HashMap}; + +use leptos::mount::UnmountHandle; +use serde::Serialize; +use wasm_bindgen::{closure::Closure, JsCast}; +use web_sys::{CustomEvent, CustomEventInit, Event, EventTarget}; + +use crate::editor_runtime::bridge_dispatch::HostCommandKind; +use crate::editor_runtime::bridge_events::{ + BridgeEnvelope, ChangePayload, HostCommandEnvelope, HostStatusPayload, ReadyPayload, + StatePayload, CHANGE_EVENT, COMMAND_EVENT, EVENT_PREFIX, PROTOCOL, READY_EVENT, RUNTIME_NAME, + RUNTIME_VERSION, SELECTION_EVENT, STATE_EVENT, STATUS_EVENT, +}; +use crate::editor_runtime::dom_selection::SelectionPayload; + +pub(crate) struct MountedRuntimeListener { + target: EventTarget, + listener: Closure, +} + +pub(crate) struct MountedRuntime { + listeners: Vec, + // 保留 Leptos mount handle,避免挂载内容被提前释放。 + #[allow(dead_code)] + mount_handle: Box, +} + +impl Drop for MountedRuntime { + fn drop(&mut self) { + for listener in &self.listeners { + let _ = listener.target.remove_event_listener_with_callback( + COMMAND_EVENT, + listener.listener.as_ref().unchecked_ref(), + ); + } + } +} + +thread_local! { + static MOUNTED_HANDLES: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); + static PENDING_RUNTIME_LISTENERS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); +} + +pub(crate) fn dispatch_runtime_event( + target: Option, + event_name: &'static str, + payload: &T, +) where + T: Serialize, +{ + let Some(target) = target else { + return; + }; + let envelope = BridgeEnvelope { + protocol: PROTOCOL, + runtime: RUNTIME_NAME, + version: RUNTIME_VERSION, + source: EVENT_PREFIX, + event: event_name, + payload, + }; + let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else { + return; + }; + + let init = CustomEventInit::new(); + init.set_detail(&detail_value); + if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) { + let _ = target.dispatch_event(&event); + } +} + +fn dispatch_custom_event_to_target(target: &EventTarget, event_name: &'static str, payload: &T) +where + T: Serialize, +{ + let envelope = BridgeEnvelope { + protocol: PROTOCOL, + runtime: RUNTIME_NAME, + version: RUNTIME_VERSION, + source: EVENT_PREFIX, + event: event_name, + payload, + }; + let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else { + return; + }; + + let init = CustomEventInit::new(); + init.set_detail(&detail_value); + init.set_bubbles(true); + if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) { + let _ = target.dispatch_event(&event); + } +} + +pub(crate) fn dispatch_ready_event_to_target(target: &EventTarget, payload: &ReadyPayload) { + dispatch_custom_event_to_target(target, READY_EVENT, payload); +} + +pub(crate) fn dispatch_change_event_to_target(target: &EventTarget, payload: &ChangePayload) { + dispatch_custom_event_to_target(target, CHANGE_EVENT, payload); +} + +pub(crate) fn dispatch_state_event_to_target(target: &EventTarget, payload: &StatePayload) { + dispatch_custom_event_to_target(target, STATE_EVENT, payload); +} + +pub(crate) fn dispatch_status_event_to_target(target: &EventTarget, payload: &HostStatusPayload) { + dispatch_custom_event_to_target(target, STATUS_EVENT, payload); +} + +pub(crate) fn dispatch_selection_event_to_target(target: &EventTarget, payload: &SelectionPayload) { + dispatch_custom_event_to_target(target, SELECTION_EVENT, payload); +} + +pub(crate) fn register_unmount_handle( + id: u32, + target: EventTarget, + listener: Closure, + handle: UnmountHandle, +) { + let mut listeners = vec![MountedRuntimeListener { target, listener }]; + PENDING_RUNTIME_LISTENERS.with(|registry| { + if let Some(mut pending) = registry.borrow_mut().remove(&id) { + listeners.append(&mut pending); + } + }); + MOUNTED_HANDLES.with(|registry| { + registry.borrow_mut().insert( + id, + MountedRuntime { + listeners, + mount_handle: Box::new(handle), + }, + ); + }); +} + +pub(crate) fn register_runtime_listener( + id: u32, + target: EventTarget, + listener: Closure, +) { + MOUNTED_HANDLES.with(|registry| { + if let Some(runtime) = registry.borrow_mut().get_mut(&id) { + runtime + .listeners + .push(MountedRuntimeListener { target, listener }); + } else { + PENDING_RUNTIME_LISTENERS.with(|pending_registry| { + pending_registry + .borrow_mut() + .entry(id) + .or_default() + .push(MountedRuntimeListener { target, listener }); + }); + } + }); +} + +pub(crate) fn take_unmount_handle(id: u32) -> Option { + PENDING_RUNTIME_LISTENERS.with(|registry| { + registry.borrow_mut().remove(&id); + }); + MOUNTED_HANDLES.with(|registry| registry.borrow_mut().remove(&id)) +} + +pub(crate) fn build_command_listener(mount_id: u32) -> Closure { + Closure::::wrap(Box::new(move |event: Event| { + let Some(custom_event) = event.dyn_ref::() else { + return; + }; + let detail = custom_event.detail(); + let Ok(envelope) = serde_wasm_bindgen::from_value::(detail) else { + return; + }; + if envelope.protocol.as_deref() != Some(PROTOCOL) { + return; + } + + let Some(payload) = envelope.payload else { + return; + }; + let Some(command_kind) = HostCommandKind::from_payload(&payload) else { + return; + }; + + let _ = mount_id; + let _ = command_kind; + })) +} diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 11b534f0..13e8374f 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -1,5 +1,5 @@ use leptos::ev; -use leptos::mount::{mount_to, mount_to_body, UnmountHandle}; +use leptos::mount::{mount_to, mount_to_body}; use leptos::prelude::*; use leptos_dom::helpers::window_event_listener; use leptos_tiptap::{ @@ -9,9 +9,9 @@ use leptos_tiptap::{ TiptapSchemaTarget, TiptapSelectionState, TiptapTextAlign, TiptapTocNodeAttrs, }; use send_wrapper::SendWrapper; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::{json, Value}; -use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display}; +use std::{cell::Cell, fmt::Display}; use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue}; use wasm_bindgen_futures::{spawn_local, JsFuture}; use web_sys::{ @@ -23,23 +23,22 @@ use editor_runtime::attachment_upload::dispatch_editor_upload_request; use editor_runtime::block_dnd::{ block_state_from_index as block_state_from_index_runtime, drop_indicator_from_point as drop_indicator_from_point_runtime, - drop_indicator_from_target as drop_indicator_from_target_runtime, - element_within_handle_shell, hovered_block_from_target as hovered_block_from_target_runtime, -}; -use editor_runtime::block_menu_overlay; -use editor_runtime::block_menu_document::{ - collect_plain_text, mindmap_paragraph_node, paragraph_node, + drop_indicator_from_target as drop_indicator_from_target_runtime, element_within_handle_shell, + hovered_block_from_target as hovered_block_from_target_runtime, }; use editor_runtime::block_hover_state::{ BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState, }; +use editor_runtime::block_menu_document::{ + collect_plain_text, mindmap_paragraph_node, paragraph_node, +}; +use editor_runtime::block_menu_overlay; +use editor_runtime::block_transform::apply_block_delta_to_json; use editor_runtime::bridge_dispatch::HostCommandKind; use editor_runtime::bridge_events::{ - BridgeEnvelope, BridgeSelectorsPayload, ChangeMetaPayload, ChangePayload, HeightPayload, - HostCommandEnvelope, HostCommandPayload, HostStatusPayload, ReadyPayload, RuntimePageOptions, - StatePayload, BLOCK_DELTA_EVENT, CHANGE_EVENT, COMMAND_EVENT, EVENT_PREFIX, HEIGHT_EVENT, - PROTOCOL, READY_EVENT, RUNTIME_NAME, RUNTIME_VERSION, SELECTION_EVENT, STATE_EVENT, - STATUS_EVENT, + BridgeSelectorsPayload, ChangeMetaPayload, ChangePayload, HeightPayload, HostCommandEnvelope, + HostCommandPayload, HostStatusPayload, ReadyPayload, RuntimePageOptions, StatePayload, + BLOCK_DELTA_EVENT, COMMAND_EVENT, HEIGHT_EVENT, PROTOCOL, RUNTIME_NAME, }; use editor_runtime::command_sync::{ read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command, @@ -50,30 +49,33 @@ use editor_runtime::dom_events::{ }; use editor_runtime::dom_selection::{ block_index_from_selection, current_block_info_from_index, runtime_block_id_from_index, - selection_payload, selection_summary, SelectionPayload, + selection_payload, selection_summary, }; use editor_runtime::editor_focus::{active_editor_stage, body_has_focus, schedule_editor_focus}; -use editor_runtime::mindmap_node_view::{ - mount_mindmap_shell_impl, unmount_mindmap_shell_impl, -}; +use editor_runtime::mindmap_node_view::{mount_mindmap_shell_impl, unmount_mindmap_shell_impl}; use editor_runtime::overlays::{ - clamp_overlay_anchor, - close_editor_floating_overlays_if_escape, - image_element_from_target, image_toolbar_anchor_from_image, open_block_menu_overlay, - open_image_toolbar_overlay, open_slash_menu_overlay, selection_bounding_rect, - should_auto_close_toolbar_overlays, sync_overlays_on_selection_change, - table_overlay_anchor_from_table, toolbar_overlay_locked, try_sync_editor_overlay_state, - FloatingToolbarAnchor, ImageToolbarAnchor, TableOverlayAnchor, TableSelectionOverlayState, + clamp_overlay_anchor, close_editor_floating_overlays_if_escape, image_element_from_target, + image_toolbar_anchor_from_image, open_block_menu_overlay, open_image_toolbar_overlay, + open_slash_menu_overlay, selection_bounding_rect, should_auto_close_toolbar_overlays, + sync_overlays_on_selection_change, table_overlay_anchor_from_table, toolbar_overlay_locked, + try_sync_editor_overlay_state, FloatingToolbarAnchor, ImageToolbarAnchor, TableOverlayAnchor, + TableSelectionOverlayState, }; +#[cfg(test)] +use editor_runtime::persistence::persisted_document_storage_key; use editor_runtime::persistence::{ load_persisted_document, normalize_identity_value, persist_document_state, persisted_document_identity, PersistedDocumentIdentity, }; +use editor_runtime::runtime_bridge::{ + build_command_listener, dispatch_change_event_to_target, dispatch_ready_event_to_target, + dispatch_runtime_event, dispatch_selection_event_to_target, dispatch_state_event_to_target, + dispatch_status_event_to_target, register_runtime_listener, register_unmount_handle, + take_unmount_handle, +}; use editor_runtime::slash_actions::{SlashActionKind, SLASH_ACTIONS}; use editor_runtime::style::SPIKE_STYLE; use editor_runtime::table_toolbar_view::table_toolbar_view; -#[cfg(test)] -use editor_runtime::persistence::persisted_document_storage_key; const EDITOR_STAGE_SELECTOR: &str = "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]"; const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror"; @@ -557,35 +559,9 @@ struct RuntimeMountOptions { mode: RuntimeDeliveryMode, } -struct MountedRuntimeListener { - target: EventTarget, - listener: Closure, -} - -struct MountedRuntime { - listeners: Vec, - // 保留 Leptos mount handle,避免挂载内容被提前释放。 - #[allow(dead_code)] - mount_handle: Box, -} - - -impl Drop for MountedRuntime { - fn drop(&mut self) { - for listener in &self.listeners { - let _ = listener.target.remove_event_listener_with_callback( - COMMAND_EVENT, - listener.listener.as_ref().unchecked_ref(), - ); - } - } -} - thread_local! { static RUNTIME_MOUNT_CONTEXT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - static MOUNTED_HANDLES: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); - static PENDING_RUNTIME_LISTENERS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); static NEXT_MOUNT_ID: Cell = const { Cell::new(1) }; } @@ -631,17 +607,6 @@ fn runtime_editor_instance_id(mount_id: Option) -> String { .unwrap_or_else(|| "mnote-leptos-tiptap-spike-standalone".to_string()) } -fn runtime_event_target() -> Option { - if let Some((_, target, _)) = runtime_mount_context() { - return Some(target); - } - - window() - .and_then(|win| win.document()) - .and_then(|document| document.body()) - .map(|body| body.into()) -} - fn editor_block_by_id(block_id: &str) -> Option { find_mnote_block_anchor(block_id) } @@ -732,125 +697,6 @@ fn schedule_restore_viewport_scroll(x: f64, y: f64, remaining: u8) { callback.forget(); } -fn dispatch_runtime_event(event_name: &'static str, payload: &T) -where - T: Serialize, -{ - let envelope = BridgeEnvelope { - protocol: PROTOCOL, - runtime: RUNTIME_NAME, - version: RUNTIME_VERSION, - source: EVENT_PREFIX, - event: event_name, - payload, - }; - let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else { - return; - }; - - let Some(target) = runtime_event_target() else { - return; - }; - - let init = CustomEventInit::new(); - init.set_detail(&detail_value); - if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) { - let _ = target.dispatch_event(&event); - } -} - -fn dispatch_custom_event_to_target(target: &EventTarget, event_name: &'static str, payload: &T) -where - T: Serialize, -{ - let envelope = BridgeEnvelope { - protocol: PROTOCOL, - runtime: RUNTIME_NAME, - version: RUNTIME_VERSION, - source: EVENT_PREFIX, - event: event_name, - payload, - }; - let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else { - return; - }; - - let init = CustomEventInit::new(); - init.set_detail(&detail_value); - init.set_bubbles(true); - if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) { - let _ = target.dispatch_event(&event); - } -} - -fn dispatch_ready_event_to_target(target: &EventTarget, payload: &ReadyPayload) { - dispatch_custom_event_to_target(target, READY_EVENT, payload); -} - -fn dispatch_change_event_to_target(target: &EventTarget, payload: &ChangePayload) { - dispatch_custom_event_to_target(target, CHANGE_EVENT, payload); -} - -fn dispatch_state_event_to_target(target: &EventTarget, payload: &StatePayload) { - dispatch_custom_event_to_target(target, STATE_EVENT, payload); -} - -fn dispatch_status_event_to_target(target: &EventTarget, payload: &HostStatusPayload) { - dispatch_custom_event_to_target(target, STATUS_EVENT, payload); -} - -fn dispatch_selection_event_to_target(target: &EventTarget, payload: &SelectionPayload) { - dispatch_custom_event_to_target(target, SELECTION_EVENT, payload); -} - -fn register_unmount_handle( - id: u32, - target: EventTarget, - listener: Closure, - handle: UnmountHandle, -) { - let mut listeners = vec![MountedRuntimeListener { target, listener }]; - PENDING_RUNTIME_LISTENERS.with(|registry| { - if let Some(mut pending) = registry.borrow_mut().remove(&id) { - listeners.append(&mut pending); - } - }); - MOUNTED_HANDLES.with(|registry| { - registry.borrow_mut().insert( - id, - MountedRuntime { - listeners, - mount_handle: Box::new(handle), - }, - ); - }); -} - -fn register_runtime_listener(id: u32, target: EventTarget, listener: Closure) { - MOUNTED_HANDLES.with(|registry| { - if let Some(runtime) = registry.borrow_mut().get_mut(&id) { - runtime - .listeners - .push(MountedRuntimeListener { target, listener }); - } else { - PENDING_RUNTIME_LISTENERS.with(|pending_registry| { - pending_registry - .borrow_mut() - .entry(id) - .or_default() - .push(MountedRuntimeListener { target, listener }); - }); - } - }); -} - -fn take_unmount_handle(id: u32) -> Option { - PENDING_RUNTIME_LISTENERS.with(|registry| { - registry.borrow_mut().remove(&id); - }); - MOUNTED_HANDLES.with(|registry| registry.borrow_mut().remove(&id)) -} - #[wasm_bindgen] pub fn mount_mindmap_shell(container: Element, options: JsValue) -> Result { console_error_panic_hook::set_once(); @@ -920,31 +766,6 @@ fn mount_app_into(target: HtmlElement, options: MountOptions, mode: RuntimeDeliv mount_id } -fn build_command_listener(mount_id: u32) -> Closure { - Closure::::wrap(Box::new(move |event: Event| { - let Some(custom_event) = event.dyn_ref::() else { - return; - }; - let detail = custom_event.detail(); - let Ok(envelope) = serde_wasm_bindgen::from_value::(detail) else { - return; - }; - if envelope.protocol.as_deref() != Some(PROTOCOL) { - return; - } - - let Some(payload) = envelope.payload else { - return; - }; - let Some(command_kind) = HostCommandKind::from_payload(&payload) else { - return; - }; - - let _ = mount_id; - let _ = command_kind; - })) -} - fn default_title() -> String { "Leptos Tiptap 主编辑器 P0".to_string() } @@ -1584,12 +1405,10 @@ fn run_slash_action( let _ = editor.focus(); editor.insert_table(4, 3, false) } - SlashActionKind::Mindmap => { - editor.insert_content( - TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)), - None, - ) - } + SlashActionKind::Mindmap => editor.insert_content( + TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)), + None, + ), SlashActionKind::Image => { dispatch_editor_upload_request("image", "image/*")?; return Ok("已打开图片上传"); @@ -2219,7 +2038,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { let current_mount_id = runtime_mount_context().map(|(id, _, _)| id); let editor_instance_id = runtime_editor_instance_id(current_mount_id); let editor_stage_id = format!("{editor_instance_id}-stage"); - let runtime_event_target = runtime_mount_context() + let runtime_target = runtime_mount_context() .map(|(_, target, _)| target) .or_else(|| { window() @@ -2367,11 +2186,12 @@ fn App(mount_options: MountOptions) -> impl IntoView { .as_ref() .and_then(|opts| opts.embed_default_block_id.clone()), ); - let command_event_target = runtime_event_target.clone(); - let ready_event_target = runtime_event_target.clone(); - let change_event_target = runtime_event_target.clone(); - let selection_event_target = runtime_event_target.clone(); - let slash_change_event_target = runtime_event_target.clone(); + let command_event_target = runtime_target.clone(); + let ready_event_target = runtime_target.clone(); + let change_event_target = runtime_target.clone(); + let selection_event_target = runtime_target.clone(); + let slash_change_event_target = runtime_target.clone(); + let height_event_target = runtime_target.clone(); { let block_menu_open = block_menu_open; @@ -2398,7 +2218,11 @@ fn App(mount_options: MountOptions) -> impl IntoView { }; let height = stage.get_bounding_client_rect().height(); if height > 0.0 { - dispatch_runtime_event(HEIGHT_EVENT, &HeightPayload { height }); + dispatch_runtime_event( + height_event_target.clone(), + HEIGHT_EVENT, + &HeightPayload { height }, + ); } }); } @@ -2639,9 +2463,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { return; }; let detail = custom_event.detail(); - let Ok(delta): Result = - serde_wasm_bindgen::from_value(detail) - else { + let Ok(delta): Result = serde_wasm_bindgen::from_value(detail) else { return; }; let Some(operations) = delta.get("operations").and_then(Value::as_array) else { @@ -2664,7 +2486,10 @@ fn App(mount_options: MountOptions) -> impl IntoView { return; } // Write back - if instance.set_content(TiptapContent::json(content.clone())).is_ok() { + 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(); @@ -2753,28 +2578,28 @@ fn App(mount_options: MountOptions) -> impl IntoView { let indicator = drop_indicator_from_point(event.client_x(), event.client_y()); if let Some(indicator) = indicator { match editor_runtime::history_safe_commands::move_top_level_block_with_history( - &editor, - source_index, - indicator.index, - indicator.placement, - ) { - Ok(()) => { - sync_persisted_editor_command( - editor, - &persisted_identity, - set_dirty_count, - set_html_output, - set_document_json, - set_json_output, - title, - set_command_feedback, - "已通过块手柄拖拽重排", - ); - } - Err(err) => { - set_command_feedback.set(err); - } + &editor, + source_index, + indicator.index, + indicator.placement, + ) { + Ok(()) => { + sync_persisted_editor_command( + editor, + &persisted_identity, + set_dirty_count, + set_html_output, + set_document_json, + set_json_output, + title, + set_command_feedback, + "已通过块手柄拖拽重排", + ); } + Err(err) => { + set_command_feedback.set(err); + } + } } else { let _ = set_command_feedback.try_set("块拖拽已取消".to_string()); } @@ -2959,8 +2784,8 @@ fn App(mount_options: MountOptions) -> impl IntoView { let set_block_turn_into_open = set_block_turn_into_open; let set_block_folded_title_open = set_block_folded_title_open; let set_command_feedback = set_command_feedback; - let keydown_capture_closure = - SendWrapper::new(Closure::wrap(Box::new(move |event: ev::KeyboardEvent| { + let keydown_capture_closure = SendWrapper::new(Closure::wrap(Box::new( + move |event: ev::KeyboardEvent| { if !active_editor_stage() || event.ctrl_key() || event.meta_key() @@ -2996,7 +2821,9 @@ fn App(mount_options: MountOptions) -> impl IntoView { let _ = set_command_feedback.try_set(format!("删除附件引用失败:{err}")); } } - }) as Box)); + }, + ) + as Box)); if let Some(win) = window() { let _ = win.add_event_listener_with_callback_and_bool( "keydown", @@ -3020,9 +2847,8 @@ fn App(mount_options: MountOptions) -> impl IntoView { if body_has_focus() && (event.ctrl_key() || event.meta_key()) && !event.alt_key() { let key = event.key(); let undo_shortcut = !event.shift_key() && key.eq_ignore_ascii_case("z"); - let redo_shortcut = - key.eq_ignore_ascii_case("y") - || (event.shift_key() && key.eq_ignore_ascii_case("z")); + let redo_shortcut = key.eq_ignore_ascii_case("y") + || (event.shift_key() && key.eq_ignore_ascii_case("z")); if undo_shortcut || redo_shortcut { event.prevent_default(); if redo_shortcut { @@ -5381,149 +5207,3 @@ pub fn standalone_main() { view! { } }); } - -// ── 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 { - 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, - 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, - 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, 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, - 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 -}