refactor: split tiptap runtime bridge

This commit is contained in:
lix-2026
2026-05-26 03:12:59 +08:00
parent 061a3cfdb9
commit cf7a57d86a
10 changed files with 444 additions and 417 deletions
@@ -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,作为最后一刀。
@@ -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::<js_sys::Function>().ok())
let Some(get_attributes) =
js_sys::Reflect::get(&editor_value, &JsValue::from_str("getAttributes"))
.ok()
.and_then(|value| value.dyn_into::<js_sys::Function>().ok())
else {
return false;
};
@@ -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};
@@ -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<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];
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<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;
};
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
}
@@ -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;
@@ -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<MountedMindmapShell> {
MOUNTED_MINDMAP_SHELLS.with(|registry| registry.borrow_mut().remove(&id))
}
pub(crate) fn mount_mindmap_shell_impl(container: JsValue, options: JsValue) -> Result<u32, JsValue> {
pub(crate) fn mount_mindmap_shell_impl(
container: JsValue,
options: JsValue,
) -> Result<u32, JsValue> {
console_error_panic_hook::set_once();
let options = serde_wasm_bindgen::from_value::<MindmapShellOptions>(options)?;
let target = container
@@ -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;
@@ -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)
@@ -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<dyn FnMut(Event)>,
}
pub(crate) struct MountedRuntime {
listeners: Vec<MountedRuntimeListener>,
// 保留 Leptos mount handle,避免挂载内容被提前释放。
#[allow(dead_code)]
mount_handle: Box<dyn Any>,
}
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<HashMap<u32, MountedRuntime>> = std::cell::RefCell::new(HashMap::new());
static PENDING_RUNTIME_LISTENERS: std::cell::RefCell<HashMap<u32, Vec<MountedRuntimeListener>>> = std::cell::RefCell::new(HashMap::new());
}
pub(crate) fn dispatch_runtime_event<T>(
target: Option<EventTarget>,
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<T>(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<M: Any + leptos::prelude::Mountable + 'static>(
id: u32,
target: EventTarget,
listener: Closure<dyn FnMut(Event)>,
handle: UnmountHandle<M>,
) {
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<dyn FnMut(Event)>,
) {
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<MountedRuntime> {
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<dyn FnMut(Event)> {
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(envelope) = serde_wasm_bindgen::from_value::<HostCommandEnvelope>(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;
}))
}
+78 -398
View File
@@ -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<dyn FnMut(Event)>,
}
struct MountedRuntime {
listeners: Vec<MountedRuntimeListener>,
// 保留 Leptos mount handle,避免挂载内容被提前释放。
#[allow(dead_code)]
mount_handle: Box<dyn Any>,
}
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<Option<RuntimeMountContext>> = const { std::cell::RefCell::new(None) };
static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell<Option<RuntimeMountOptions>> = const { std::cell::RefCell::new(None) };
static MOUNTED_HANDLES: std::cell::RefCell<HashMap<u32, MountedRuntime>> = std::cell::RefCell::new(HashMap::new());
static PENDING_RUNTIME_LISTENERS: std::cell::RefCell<HashMap<u32, Vec<MountedRuntimeListener>>> = std::cell::RefCell::new(HashMap::new());
static NEXT_MOUNT_ID: Cell<u32> = const { Cell::new(1) };
}
@@ -631,17 +607,6 @@ fn runtime_editor_instance_id(mount_id: Option<u32>) -> String {
.unwrap_or_else(|| "mnote-leptos-tiptap-spike-standalone".to_string())
}
fn runtime_event_target() -> Option<EventTarget> {
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<Element> {
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<T>(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<T>(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<M: Any + leptos::prelude::Mountable + 'static>(
id: u32,
target: EventTarget,
listener: Closure<dyn FnMut(Event)>,
handle: UnmountHandle<M>,
) {
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<dyn FnMut(Event)>) {
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<MountedRuntime> {
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<u32, JsValue> {
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<dyn FnMut(Event)> {
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(envelope) = serde_wasm_bindgen::from_value::<HostCommandEnvelope>(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<Value, _> =
serde_wasm_bindgen::from_value(detail)
else {
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 {
@@ -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<dyn FnMut(_)>));
},
)
as Box<dyn FnMut(_)>));
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! { <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
}