feat: finish rust web dual pane document shell

This commit is contained in:
lix-2026
2026-05-08 23:15:00 +08:00
parent 83805f9254
commit 3d5e0c9d5a
16 changed files with 3776 additions and 761 deletions
+321 -136
View File
@@ -19,7 +19,7 @@ use web_sys::{
MouseEvent, Node, RequestInit, RequestMode, Response, Storage, WheelEvent,
};
const EDITOR_STAGE_SELECTOR: &str = "#editor-stage";
const EDITOR_STAGE_SELECTOR: &str = "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]";
const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror";
const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell";
const SPIKE_STORAGE_KEY: &str = "mnote.leptos-tiptap-spike.document";
@@ -2497,6 +2497,22 @@ mod tests {
);
}
#[test]
fn runtime_editor_instance_id_uses_mount_id_when_available() {
assert_eq!(
runtime_editor_instance_id(Some(7)),
"mnote-leptos-tiptap-spike-7"
);
}
#[test]
fn runtime_editor_instance_id_falls_back_for_standalone_mode() {
assert_eq!(
runtime_editor_instance_id(None),
"mnote-leptos-tiptap-spike-standalone"
);
}
#[test]
fn persisted_document_storage_key_isolated_per_document_identity() {
let doc_a =
@@ -2625,6 +2641,13 @@ fn runtime_mount_options() -> Option<RuntimeMountOptions> {
RUNTIME_MOUNT_OPTIONS.with(|cell| cell.borrow().clone())
}
fn runtime_editor_instance_id(mount_id: Option<u32>) -> String {
mount_id
.filter(|value| *value > 0)
.map(|value| format!("mnote-leptos-tiptap-spike-{value}"))
.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);
@@ -2762,6 +2785,40 @@ fn schedule_scroll_mnote_block_anchor_from_hash_retry(remaining: u8) {
callback.forget();
}
fn current_viewport_scroll() -> Option<(f64, f64)> {
let win = window()?;
let x = win.scroll_x().ok()?;
let y = win.scroll_y().ok()?;
Some((x, y))
}
fn restore_viewport_scroll(x: f64, y: f64) {
if let Some(win) = window() {
win.scroll_to_with_x_and_y(x, y);
}
}
fn schedule_restore_viewport_scroll(x: f64, y: f64, remaining: u8) {
restore_viewport_scroll(x, y);
let Some(win) = window() else {
return;
};
if remaining == 0 {
return;
}
let callback = Closure::<dyn FnMut()>::new(move || {
restore_viewport_scroll(x, y);
if remaining > 1 {
schedule_restore_viewport_scroll(x, y, remaining - 1);
}
});
let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
callback.as_ref().unchecked_ref(),
80,
);
callback.forget();
}
fn dispatch_runtime_event<T>(event_name: &'static str, payload: &T)
where
T: Serialize,
@@ -2817,22 +2874,42 @@ fn dispatch_ready_event(payload: &ReadyPayload) {
dispatch_runtime_event(READY_EVENT, payload);
}
fn dispatch_ready_event_to_target(target: &EventTarget, payload: &ReadyPayload) {
dispatch_custom_event_to_target(target, READY_EVENT, payload);
}
fn dispatch_change_event(payload: &ChangePayload) {
dispatch_runtime_event(CHANGE_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(payload: &StatePayload) {
dispatch_runtime_event(STATE_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(payload: &HostStatusPayload) {
dispatch_runtime_event(STATUS_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(payload: &SelectionPayload) {
dispatch_runtime_event(SELECTION_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,
@@ -4960,6 +5037,52 @@ fn dispatch_runtime_state(
));
}
fn dispatch_runtime_state_to_target(
target: &EventTarget,
document_id: Option<String>,
workspace_id: Option<String>,
title: String,
dirty_count: u32,
hovered_block: Option<HoveredBlockState>,
editor_focused: bool,
slash_open: bool,
turn_into_open: bool,
color_menu_open: bool,
more_menu_open: bool,
read_only: bool,
) {
let state = state_payload(
document_id.clone(),
workspace_id.clone(),
title.clone(),
dirty_count,
hovered_block.clone(),
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
read_only,
);
dispatch_state_event_to_target(target, &state);
dispatch_status_event_to_target(
target,
&status_payload(
document_id,
workspace_id,
title,
dirty_count,
hovered_block,
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
read_only,
),
);
}
fn send_selection_state(
selection: &TiptapSelectionState,
editor_focused: bool,
@@ -4972,6 +5095,18 @@ fn send_selection_state(
));
}
fn send_selection_state_to_target(
target: &EventTarget,
selection: &TiptapSelectionState,
editor_focused: bool,
current_block_index: Option<usize>,
) {
dispatch_selection_event_to_target(
target,
&selection_payload(selection, editor_focused, current_block_index),
);
}
fn apply_host_document_payload(
editor: TiptapEditorHandle,
payload: HostDocumentPayload,
@@ -5004,6 +5139,7 @@ fn apply_host_document_payload(
set_conflict_detection_key.set(next_conflict_detection_key.clone());
if let Some(content) = payload.content {
let viewport_scroll = current_viewport_scroll();
let next_content = TiptapContent::json(content);
match editor.set_content(next_content) {
Ok(()) => {
@@ -5015,6 +5151,9 @@ fn apply_host_document_payload(
);
set_dirty_count.set(0);
set_command_feedback.set("宿主文档已同步到编辑器".to_string());
if let Some((x, y)) = viewport_scroll {
schedule_restore_viewport_scroll(x, y, 10);
}
}
Err(err) => {
set_command_feedback.set(format!("宿主文档同步失败:{err}"));
@@ -6303,6 +6442,16 @@ fn normalize_layout_density(value: Option<String>) -> String {
#[component]
fn App(mount_options: MountOptions) -> impl IntoView {
let editor = TiptapEditorHandle::new();
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()
.map(|(_, target, _)| target)
.or_else(|| {
window()
.and_then(|win| win.document())
.map(|document| document.into())
});
let initial_document_id = mount_options
.document_id
.clone()
@@ -6420,6 +6569,11 @@ 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 block_menu_open = block_menu_open;
@@ -6483,6 +6637,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let set_show_heading_numbers = set_show_heading_numbers;
let set_embed_default_block_id = set_embed_default_block_id;
move |_| {
let command_event_target = command_event_target.clone();
let command_listener =
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
@@ -6564,19 +6719,22 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let editable = payload.editable.unwrap_or(true);
set_editor_editable.set(editable);
set_read_only.set(!editable);
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
if let Some(target) = command_event_target.as_ref() {
dispatch_runtime_state_to_target(
target,
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
}
}
HostCommandKind::SetPageOptions => {
if let Some(page_options) = payload.page_options {
@@ -6602,22 +6760,27 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let current_block = current_block_info_from_index(
hovered_block.get_untracked().map(|block| block.index),
);
dispatch_status_event(&HostStatusPayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
dirty_count: dirty_count.get_untracked(),
selected_block_index: current_block.index,
current_block_id: current_block.block_id,
editor_focused: editor_focused.get_untracked(),
read_only: read_only.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
});
if let Some(target) = command_event_target.as_ref() {
dispatch_status_event_to_target(
target,
&HostStatusPayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
dirty_count: dirty_count.get_untracked(),
selected_block_index: current_block.index,
current_block_id: current_block.block_id,
editor_focused: editor_focused.get_untracked(),
read_only: read_only.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
},
);
}
}
HostCommandKind::InsertInlineReference
| HostCommandKind::InsertEmbedReference => {
@@ -7379,7 +7542,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}}
<div
id="editor-stage"
id=editor_stage_id.clone()
class="editor-stage"
data-testid="mnote-leptos-tiptap-editor-stage"
data-page-wide-layout=move || wide_layout.get().to_string()
@@ -9169,6 +9332,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let show_category = index == 0
|| SLASH_ACTIONS[index - 1].category != category;
let testid = format!("slash-item-{}", action.id);
let slash_change_event_target = slash_change_event_target.clone();
view! {
<>
{if show_category {
@@ -9189,39 +9353,45 @@ fn App(mount_options: MountOptions) -> impl IntoView {
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
dispatch_change_event(&ChangePayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
content: snapshot.clone(),
meta: ChangeMetaPayload {
dirty_count: dirty_count.get_untracked(),
editor_focused: editor_focused.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
revision: revision.get_untracked(),
conflict_detection_key: conflict_detection_key.get_untracked(),
read_only: read_only.get_untracked(),
},
});
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
if let Some(target) = slash_change_event_target.as_ref() {
dispatch_change_event_to_target(
target,
&ChangePayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
content: snapshot.clone(),
meta: ChangeMetaPayload {
dirty_count: dirty_count.get_untracked(),
editor_focused: editor_focused.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
revision: revision.get_untracked(),
conflict_detection_key: conflict_detection_key.get_untracked(),
read_only: read_only.get_untracked(),
},
},
);
dispatch_runtime_state_to_target(
target,
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
}
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
@@ -9261,7 +9431,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}}
<TiptapEditor
id="mnote-leptos-tiptap-spike"
id=editor_instance_id.clone()
editor=editor
initial_content=initial_editor_content.clone()
placeholder="输入 “/” 打开命令菜单;试试 heading / list / todo / quote / code block / divider"
@@ -9290,39 +9460,45 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let snapshot =
sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
schedule_scroll_mnote_block_anchor_from_hash();
dispatch_ready_event(&ReadyPayload {
runtime_name: RUNTIME_NAME,
selectors: BridgeSelectorsPayload {
root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
},
supported_commands: vec![
"replaceContent",
"setEditable",
"undo",
"redo",
"focus",
"requestCurrentBlockId",
],
supports_embedded_mode: true,
});
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
if let Some(target) = ready_event_target.as_ref() {
dispatch_ready_event_to_target(
target,
&ReadyPayload {
runtime_name: RUNTIME_NAME,
selectors: BridgeSelectorsPayload {
root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
},
supported_commands: vec![
"replaceContent",
"setEditable",
"undo",
"redo",
"focus",
"requestCurrentBlockId",
],
supports_embedded_mode: true,
},
);
dispatch_runtime_state_to_target(
target,
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
}
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
@@ -9345,39 +9521,45 @@ fn App(mount_options: MountOptions) -> impl IntoView {
set_dirty_count.update(|count| *count += 1);
let snapshot =
sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
dispatch_change_event(&ChangePayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
content: snapshot.clone(),
meta: ChangeMetaPayload {
dirty_count: dirty_count.get_untracked(),
editor_focused: editor_focused.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
revision: revision.get_untracked(),
conflict_detection_key: conflict_detection_key.get_untracked(),
read_only: read_only.get_untracked(),
},
});
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
if let Some(target) = change_event_target.as_ref() {
dispatch_change_event_to_target(
target,
&ChangePayload {
document_id: document_id.get_untracked(),
workspace_id: workspace_id.get_untracked(),
title: title.get_untracked(),
content: snapshot.clone(),
meta: ChangeMetaPayload {
dirty_count: dirty_count.get_untracked(),
editor_focused: editor_focused.get_untracked(),
slash_open: slash_open.get_untracked(),
toolbar_open: toolbar_overlay_locked(
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
),
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
revision: revision.get_untracked(),
conflict_detection_key: conflict_detection_key.get_untracked(),
read_only: read_only.get_untracked(),
},
},
);
dispatch_runtime_state_to_target(
target,
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
}
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
@@ -9422,11 +9604,14 @@ fn App(mount_options: MountOptions) -> impl IntoView {
set_block_menu_anchor.set(None);
set_hovered_block.set(None);
}
send_selection_state(
&selection_clone,
editor_focused.get_untracked(),
hovered_block.get_untracked().map(|block| block.index),
);
if let Some(target) = selection_event_target.as_ref() {
send_selection_state_to_target(
target,
&selection_clone,
editor_focused.get_untracked(),
hovered_block.get_untracked().map(|block| block.index),
);
}
}
attr:class="editor-surface"
attr:data-testid="mnote-leptos-tiptap-editor-root"