refactor: split tiptap runtime bridge
This commit is contained in:
+1
-1
@@ -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;
|
||||
}))
|
||||
}
|
||||
@@ -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 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