use leptos::ev; use leptos::mount::mount_to_body; use leptos::prelude::*; use leptos_dom::helpers::window_event_listener; use leptos_tiptap::{ TiptapContent, TiptapEditor, TiptapEditorHandle, TiptapExtension, TiptapLinkResource, TiptapMarkName, TiptapSelectionState, TiptapTextAlign, }; use send_wrapper::SendWrapper; use serde::Deserialize; use serde_json::{json, Value}; use std::fmt::Display; use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue}; use wasm_bindgen_futures::spawn_local; use web_sys::{ window, CustomEvent, DragEvent, Element, Event, EventTarget, HtmlElement, MouseEvent, WheelEvent, }; 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_hover_state::{ BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState, }; use editor_runtime::block_menu_overlay; use editor_runtime::block_transform::{ apply_block_delta_to_json, apply_highlight_color, apply_image_align, apply_text_align, apply_text_color, clear_text_color, insert_editor_paragraph_relative_to_block, prosemirror_node_size, run_block_turn_into_action, run_block_turn_into_folded_heading_action, run_block_turn_into_page_action, run_slash_action, top_level_block_boundary_position, top_level_block_is_page, top_level_block_matches_action, top_level_block_range, }; use editor_runtime::bridge_dispatch::HostCommandKind; use editor_runtime::bridge_events::{ BridgeSelectorsPayload, ChangeMetaPayload, ChangePayload, HeightPayload, HostCommandEnvelope, HostCommandPayload, HostStatusPayload, ReadyPayload, 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, }; use editor_runtime::content_layout::{content_column_left, content_text_left}; use editor_runtime::dom_events::{ event_target_matches_selector, target_element, trap_scroll_inside_menu, }; use editor_runtime::dom_selection::{ block_index_from_selection, current_block_info_from_index, runtime_block_id_from_index, 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::mount::{ initial_content, mount_app_into, runtime_delivery_mode, runtime_editor_instance_id, runtime_mount_context, set_runtime_mount_context, set_runtime_mount_options, MountOptions, RuntimeDeliveryMode, RuntimeStandaloneObject, }; #[cfg(test)] use editor_runtime::mount::resolve_runtime_delivery_mode; 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, }; #[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::{ 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, take_unmount_handle, }; use editor_runtime::slash_actions::{SlashActionKind, SLASH_ACTIONS}; use editor_runtime::slash_menu_view::{ slash_menu_view, SlashMenuRuntimeContext, SlashMenuSignals, SlashMenuViewProps, }; use editor_runtime::style::SPIKE_STYLE; use editor_runtime::table_toolbar_view::table_toolbar_view; 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 HANDLE_STAGE_PADDING_LEFT: f64 = 18.0; const HANDLE_TRIGGER_WIDTH: f64 = 22.0; const HANDLE_TRIGGER_GAP: f64 = 4.0; const HANDLE_MENU_GAP: f64 = 0.0; const HANDLE_TEXT_ALIGN_OFFSET: f64 = 96.0; mod editor_runtime; #[wasm_bindgen(inline_js = r#" export function write_mnote_text_to_clipboard(text) { try { if (navigator?.clipboard?.writeText) { return navigator.clipboard.writeText(String(text)).then(() => true, () => false); } } catch (_error) {} return Promise.resolve(false); } export function find_mnote_block_anchor(blockId) { const wanted = String(blockId || ''); if (!wanted) return null; const root = document.querySelector('.editor-surface .ProseMirror'); if (!root) return null; return Array.from(root.querySelectorAll('[data-block-id]')).find((node) => node.getAttribute('data-block-id') === wanted) || null; } export function select_mnote_image_node(image) { try { const root = image?.closest?.('.ProseMirror'); const editor = root?.editor; const view = editor?.view; if (!editor || !view || typeof view.posAtDOM !== 'function') return false; const pos = view.posAtDOM(image, 0); if (!Number.isFinite(pos)) return false; return editor.chain().focus().setNodeSelection(pos).run() === true; } catch (error) { console.warn('mnote image NodeSelection failed', error); return false; } } function mnoteImageExtension(url, contentType) { const urlMatch = String(url || '').match(/\.([a-zA-Z0-9]+)(?:[?#].*)?$/); if (urlMatch?.[1]) return `.${urlMatch[1].toLowerCase()}`; const mimeMap = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg', 'image/bmp': '.bmp', }; return mimeMap[String(contentType || '').toLowerCase()] || '.jpg'; } function mnoteImageFilename(image, url) { const raw = image.getAttribute('title') || image.getAttribute('alt') || `image-${Date.now()}`; const base = raw.replace(/[\/:*?"<>|]+/g, '-').trim() || `image-${Date.now()}`; return /\.[a-zA-Z0-9]+$/.test(base) ? base : `${base}${mnoteImageExtension(url)}`; } export function download_mnote_selected_image() { try { const image = document.querySelector('.editor-surface .ProseMirror img.ProseMirror-selectednode[src]'); if (!(image instanceof HTMLImageElement)) return false; const src = image.getAttribute('src'); if (!src) return false; const url = new URL(src, window.location.href); if (!(url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'data:')) return false; const link = document.createElement('a'); link.href = url.href; link.download = mnoteImageFilename(image, url.href); link.style.display = 'none'; document.body.appendChild(link); link.click(); link.remove(); return true; } catch (error) { console.warn('mnote image download failed', error); return false; } } export function bump_mnote_e27_ai_bridge_request_count() { try { const key = '__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__'; window[key] = Number(window[key] || 0) + 1; return window[key]; } catch (_error) { return 0; } } export async function post_mnote_document_save(body) { const response = await fetch('/api/documents/save', { method: 'POST', headers: { 'content-type': 'application/json' }, body: String(body || '{}'), }); const text = await response.text(); if (!response.ok) { throw new Error(`document save failed: HTTP ${response.status}; ${text.slice(0, 1200)}`); } return text || '{}'; } "#)] extern "C" { #[wasm_bindgen(catch)] async fn write_mnote_text_to_clipboard(text: String) -> Result; fn find_mnote_block_anchor(block_id: &str) -> Option; fn select_mnote_image_node(image: &Element) -> bool; fn download_mnote_selected_image() -> bool; fn bump_mnote_e27_ai_bridge_request_count() -> u32; #[wasm_bindgen(catch)] async fn post_mnote_document_save(body: String) -> Result; } #[derive(Clone, Copy)] struct ToolbarColorOption { id: &'static str, label: &'static str, value: &'static str, } #[derive(Clone, Copy)] struct ToolbarAlignOption { id: &'static str, label: &'static str, alignment: TiptapTextAlign, } const TOOLBAR_TEXT_COLORS: [ToolbarColorOption; 10] = [ ToolbarColorOption { id: "default", label: "默认文字", value: "#111827", }, ToolbarColorOption { id: "gray", label: "灰色", value: "#6b7280", }, ToolbarColorOption { id: "brown", label: "棕色", value: "#8b5e3c", }, ToolbarColorOption { id: "orange", label: "橙色", value: "#c96b1f", }, ToolbarColorOption { id: "yellow", label: "黄色", value: "#b7791f", }, ToolbarColorOption { id: "green", label: "绿色", value: "#18794e", }, ToolbarColorOption { id: "blue", label: "蓝色", value: "#1d4ed8", }, ToolbarColorOption { id: "purple", label: "紫色", value: "#7c3aed", }, ToolbarColorOption { id: "pink", label: "粉色", value: "#be185d", }, ToolbarColorOption { id: "red", label: "红色", value: "#b91c1c", }, ]; const TOOLBAR_HIGHLIGHT_COLORS: [ToolbarColorOption; 8] = [ ToolbarColorOption { id: "yellow", label: "黄色背景", value: "#fef3c7", }, ToolbarColorOption { id: "green", label: "绿色背景", value: "#dcfce7", }, ToolbarColorOption { id: "blue", label: "蓝色背景", value: "#dbeafe", }, ToolbarColorOption { id: "purple", label: "紫色背景", value: "#ede9fe", }, ToolbarColorOption { id: "pink", label: "粉色背景", value: "#fce7f3", }, ToolbarColorOption { id: "red", label: "红色背景", value: "#fee2e2", }, ToolbarColorOption { id: "orange", label: "橙色背景", value: "#ffedd5", }, ToolbarColorOption { id: "gray", label: "灰色背景", value: "#e5e7eb", }, ]; const TOOLBAR_ALIGN_OPTIONS: [ToolbarAlignOption; 4] = [ ToolbarAlignOption { id: "left", label: "左对齐", alignment: TiptapTextAlign::Left, }, ToolbarAlignOption { id: "center", label: "居中", alignment: TiptapTextAlign::Center, }, ToolbarAlignOption { id: "right", label: "右对齐", alignment: TiptapTextAlign::Right, }, ToolbarAlignOption { id: "justify", label: "两端对齐", alignment: TiptapTextAlign::Justify, }, ]; #[derive(Clone, Copy)] struct FoldedHeadingAction { level: u8, id: &'static str, icon: &'static str, label: &'static str, } const FOLDED_HEADING_ACTIONS: [FoldedHeadingAction; 4] = [ FoldedHeadingAction { level: 1, id: "1", icon: "H1", label: "折叠主标题", }, FoldedHeadingAction { level: 2, id: "2", icon: "H2", label: "折叠大标题", }, FoldedHeadingAction { level: 3, id: "3", icon: "H3", label: "折叠中标题", }, FoldedHeadingAction { level: 4, id: "4", icon: "H4", label: "折叠小标题", }, ]; #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] #[allow(dead_code)] struct LegacyHostEnvelope { protocol: Option, runtime: Option, version: Option, source: Option, event: Option, payload: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct HostDocumentPayload { document_id: Option, workspace_id: Option, title: Option, content: Option, revision: Option, conflict_detection_key: Option, read_only: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn resolves_embedded_mode_from_explicit_runtime_mode_first() { assert_eq!( resolve_runtime_delivery_mode(Some(RuntimeDeliveryMode::Embedded), false), RuntimeDeliveryMode::Embedded ); assert_eq!( resolve_runtime_delivery_mode(Some(RuntimeDeliveryMode::Standalone), true), RuntimeDeliveryMode::Standalone ); } #[test] fn resolves_embedded_mode_from_url_when_runtime_mode_absent() { assert_eq!( resolve_runtime_delivery_mode(None, true), RuntimeDeliveryMode::Embedded ); assert_eq!( resolve_runtime_delivery_mode(None, false), RuntimeDeliveryMode::Standalone ); } #[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 = persisted_document_identity(Some("doc-a".to_string()), Some("ws-1".to_string())); let doc_b = persisted_document_identity(Some("doc-b".to_string()), Some("ws-1".to_string())); let doc_without_workspace = persisted_document_identity(Some("doc-a".to_string()), None); assert_eq!( persisted_document_storage_key(&doc_a), "mnote.leptos-tiptap-spike.document:ws-1:doc-a" ); assert_eq!( persisted_document_storage_key(&doc_b), "mnote.leptos-tiptap-spike.document:ws-1:doc-b" ); assert_eq!( persisted_document_storage_key(&doc_without_workspace), "mnote.leptos-tiptap-spike.document:doc-a" ); assert_ne!( persisted_document_storage_key(&doc_a), persisted_document_storage_key(&doc_b) ); } #[test] fn standalone_mindmap_object_uses_isolated_draft_identity() { let object = RuntimeStandaloneObject { kind: Some("mindmap".to_string()), document_id: Some("doc-a".to_string()), mindmap_id: Some("mind-a".to_string()), }; let identity = persisted_mindmap_object_identity(Some(&object)).expect("identity"); assert_eq!( persisted_document_storage_key(&identity), "mnote.leptos-tiptap-spike.document:mindmap-object:doc-a:mind-a" ); } #[test] fn mindmap_empty_paragraph_has_textblock_boundary_size() { let node = json!({ "type": "paragraph", "attrs": { "mnoteBlockType": "mindmap", "mindmapId": "思维导图123456.json", "rootNodeId": "root", "projectionVersion": 1 } }); assert_eq!(prosemirror_node_size(&node), Some(2)); let document = json!({ "type": "doc", "content": [ {"type": "paragraph", "content": [{"type": "text", "text": "上"}]}, node, {"type": "paragraph", "content": [{"type": "text", "text": "下"}]} ] }); assert_eq!( top_level_block_boundary_position(&document, 1, true), Some(3) ); assert_eq!( top_level_block_boundary_position(&document, 1, false), Some(5) ); } #[test] fn slash_menu_css_uses_viewport_overlay_layer() { assert!(SPIKE_STYLE.contains(".slash-menu")); assert!(SPIKE_STYLE.contains("position: fixed;")); assert!(SPIKE_STYLE.contains("z-index: 130;")); assert!(!SPIKE_STYLE.contains("z-index: 11;")); } } fn editor_block_by_id(block_id: &str) -> Option { find_mnote_block_anchor(block_id) } fn current_page_anchor_url(block_id: &str) -> Option { let win = window()?; let location = win.location(); let origin = location.origin().ok()?; let pathname = location.pathname().ok()?; let search = location.search().ok().unwrap_or_default(); Some(format!("{}{}{}#{}", origin, pathname, search, block_id)) } fn scroll_mnote_block_anchor(block_id: &str) -> bool { let Some(block) = editor_block_by_id(block_id) else { return false; }; let _ = block.scroll_into_view_with_bool(true); true } fn current_location_hash_block_id() -> Option { window() .and_then(|win| win.location().hash().ok()) .map(|hash| hash.trim_start_matches('#').trim().to_string()) .filter(|hash| !hash.is_empty()) } fn scroll_mnote_block_anchor_from_hash() -> bool { current_location_hash_block_id() .as_deref() .map(scroll_mnote_block_anchor) .unwrap_or(false) } fn schedule_scroll_mnote_block_anchor_from_hash() { schedule_scroll_mnote_block_anchor_from_hash_retry(60); } fn schedule_scroll_mnote_block_anchor_from_hash_retry(remaining: u8) { let Some(win) = window() else { return; }; let callback = Closure::::new(move || { let _ = scroll_mnote_block_anchor_from_hash(); if remaining > 1 { schedule_scroll_mnote_block_anchor_from_hash_retry(remaining - 1); } }); let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0( callback.as_ref().unchecked_ref(), 120, ); 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::::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(); } #[wasm_bindgen] pub fn mount_mindmap_shell(container: Element, options: JsValue) -> Result { console_error_panic_hook::set_once(); mount_mindmap_shell_impl(container.into(), options) } #[wasm_bindgen] pub fn unmount_mindmap_shell(mount_id: u32) -> Result<(), JsValue> { unmount_mindmap_shell_impl(mount_id) } #[wasm_bindgen] pub fn mount(container: Element, options: JsValue) -> Result { console_error_panic_hook::set_once(); let options = if options.is_undefined() || options.is_null() { MountOptions::default() } else { serde_wasm_bindgen::from_value(options)? }; let target = container .dyn_into::() .map_err(|_| JsValue::from_str("mount 目标必须是 HTML 元素"))?; let mount_id = mount_app_into(target, options, RuntimeDeliveryMode::Embedded); Ok(mount_id) } #[wasm_bindgen] pub fn unmount(mount_id: u32) -> Result<(), JsValue> { if let Some(handle) = take_unmount_handle(mount_id) { if runtime_mount_context().map(|(id, _, _)| id) == Some(mount_id) { set_runtime_mount_context(None); set_runtime_mount_options(None, RuntimeDeliveryMode::Embedded); } drop(handle); Ok(()) } else { Err(JsValue::from_str("找不到对应的挂载句柄")) } } fn default_title() -> String { "Leptos Tiptap 主编辑器 P0".to_string() } fn current_query_param(key: &str) -> Option { window() .and_then(|win| win.location().search().ok()) .and_then(|search| { let query = search.trim_start_matches('?'); query.split('&').find_map(|pair| { let (candidate_key, candidate_value) = pair.split_once('=')?; if candidate_key == key && !candidate_value.trim().is_empty() { Some(candidate_value.trim().to_string()) } else { None } }) }) } fn resolve_persisted_document_identity( explicit_document_id: Option, explicit_workspace_id: Option, ) -> PersistedDocumentIdentity { persisted_document_identity( explicit_document_id.or_else(|| current_query_param("documentId")), explicit_workspace_id.or_else(|| current_query_param("workspaceId")), ) } fn persisted_mindmap_object_identity( object: Option<&RuntimeStandaloneObject>, ) -> Option { let object = object?; if object.kind.as_deref() != Some("mindmap") { return None; } let document_id = normalize_identity_value(object.document_id.clone())?; let mindmap_id = normalize_identity_value(object.mindmap_id.clone())?; Some(persisted_document_identity( Some(format!("mindmap-object:{document_id}:{mindmap_id}")), None, )) } fn runtime_persisted_identity( document_id: ReadSignal>, workspace_id: ReadSignal>, ) -> PersistedDocumentIdentity { persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked()) } fn is_embedded_mode() -> bool { runtime_delivery_mode() == RuntimeDeliveryMode::Embedded } fn current_document_id() -> Option { current_query_param("documentId") } fn current_workspace_id() -> Option { current_query_param("workspaceId") } pub(crate) fn editor_root_element() -> Option { window() .and_then(|win| win.document()) .and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten()) } pub(crate) fn editor_stage_element() -> Option { window() .and_then(|win| win.document()) .and_then(|document| { document .query_selector(EDITOR_STAGE_SELECTOR) .ok() .flatten() }) } fn handle_lane_left(stage_rect_width: f64) -> f64 { (content_text_left(stage_rect_width) + HANDLE_TEXT_ALIGN_OFFSET - HANDLE_TRIGGER_WIDTH - HANDLE_TRIGGER_GAP - HANDLE_MENU_GAP) .max(HANDLE_STAGE_PADDING_LEFT) } fn hovered_block_from_target(target: web_sys::EventTarget) -> Option { let root = editor_root_element()?; hovered_block_from_target_runtime(target, &root, HANDLE_SHELL_SELECTOR) } fn hovered_block_from_selection() -> Option { block_index_from_selection().and_then(block_state_from_index) } fn block_state_from_index(index: usize) -> Option { block_state_from_index_runtime(index) } fn slash_menu_anchor_style() -> String { const MENU_WIDTH: f64 = 316.0; const MENU_HEIGHT: f64 = 430.0; const GAP: f64 = 8.0; let selection_rect = selection_bounding_rect() .filter(|rect| rect.top() > 0.0 || rect.left() > 0.0 || rect.height() > 0.0); let (raw_top, raw_left, raw_anchor_top) = if let Some(rect) = selection_rect { (rect.bottom() + GAP, rect.left(), rect.top()) } else if let Some(block) = hovered_block_from_selection() { if let Some(stage) = editor_stage_element() { let stage_rect = stage.get_bounding_client_rect(); ( stage_rect.top() + block.top + block.height + GAP, stage_rect.left() + content_text_left(stage_rect.width()), stage_rect.top() + block.top, ) } else { (24.0, 24.0, 24.0) } } else { (24.0, 24.0, 24.0) }; let (clamped_top, clamped_left) = clamp_overlay_anchor( raw_top, raw_left, raw_anchor_top, MENU_WIDTH, MENU_HEIGHT, GAP, ); format!("top:{clamped_top:.1}px;left:{clamped_left:.1}px;") } fn drop_indicator_from_target( target: web_sys::EventTarget, client_y: i32, ) -> Option { drop_indicator_from_target_runtime(target, client_y, HANDLE_SHELL_SELECTOR) } fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option { drop_indicator_from_point_runtime(client_x, client_y, HANDLE_SHELL_SELECTOR) } fn p0_extensions() -> Vec { vec![ TiptapExtension::Document, TiptapExtension::Dropcursor, TiptapExtension::Gapcursor, TiptapExtension::Text, TiptapExtension::Paragraph, TiptapExtension::Heading, TiptapExtension::Bold, TiptapExtension::Italic, TiptapExtension::Strike, TiptapExtension::Code, TiptapExtension::Blockquote, TiptapExtension::BulletList, TiptapExtension::OrderedList, TiptapExtension::ListItem, TiptapExtension::TaskItem, TiptapExtension::TaskList, TiptapExtension::CodeBlock, TiptapExtension::HorizontalRule, TiptapExtension::Image, TiptapExtension::Table, TiptapExtension::TableRow, TiptapExtension::TableCell, TiptapExtension::TableHeader, TiptapExtension::TocNode, TiptapExtension::History, TiptapExtension::Underline, TiptapExtension::TextStyle, TiptapExtension::TextAlign, TiptapExtension::Highlight, TiptapExtension::Link, TiptapExtension::Placeholder, ] } fn state_payload( document_id: Option, workspace_id: Option, title: String, dirty_count: u32, hovered_block: Option, editor_focused: bool, slash_open: bool, turn_into_open: bool, color_menu_open: bool, more_menu_open: bool, read_only: bool, ) -> StatePayload { let toolbar_open = toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open); StatePayload { document_id, workspace_id, title, dirty_count, selected_block_index: hovered_block.as_ref().map(|block| block.index), editor_focused, slash_open, toolbar_open, read_only, } } fn status_payload( document_id: Option, workspace_id: Option, title: String, dirty_count: u32, hovered_block: Option, editor_focused: bool, slash_open: bool, turn_into_open: bool, color_menu_open: bool, more_menu_open: bool, read_only: bool, ) -> HostStatusPayload { let current_block_index = hovered_block.as_ref().map(|block| block.index); let current_block_id = current_block_index.and_then(runtime_block_id_from_index); HostStatusPayload { document_id, workspace_id, title, dirty_count, selected_block_index: current_block_index, current_block_id, editor_focused, read_only, slash_open, toolbar_open: toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open), } } fn dispatch_runtime_state_to_target( target: &EventTarget, document_id: Option, workspace_id: Option, title: String, dirty_count: u32, hovered_block: Option, 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_to_target( target: &EventTarget, selection: &TiptapSelectionState, editor_focused: bool, current_block_index: Option, ) { dispatch_selection_event_to_target( target, &selection_payload(selection, editor_focused, current_block_index), ); } fn apply_host_document_payload( editor: TiptapEditorHandle, payload: HostDocumentPayload, set_document_id: WriteSignal>, set_workspace_id: WriteSignal>, set_title: WriteSignal, set_read_only: WriteSignal, set_revision: WriteSignal>, set_conflict_detection_key: WriteSignal>, set_dirty_count: WriteSignal, set_html_output: WriteSignal, set_document_json: WriteSignal, set_json_output: WriteSignal, set_command_feedback: WriteSignal, ) { let next_document_id = payload.document_id.clone(); let next_workspace_id = payload.workspace_id.clone(); let next_title = payload.title.clone(); let next_revision = payload.revision; let next_conflict_detection_key = payload.conflict_detection_key.clone(); let next_read_only = payload.read_only.unwrap_or(false); set_document_id.set(next_document_id); set_workspace_id.set(next_workspace_id); if let Some(next_title_value) = next_title { set_title.set(next_title_value); } set_read_only.set(next_read_only); set_revision.set(next_revision); 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(()) => { let _snapshot = sync_editor_outputs( editor, set_html_output, set_document_json, set_json_output, ); 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}")); } } } } fn apply_feedback_result(setter: WriteSignal, result: Result<&'static str, E>) where E: Display, { match result { Ok(message) => setter.set(message.to_string()), Err(err) => setter.set(format!("命令执行失败:{err}")), } } fn apply_ok_feedback( setter: WriteSignal, result: Result<(), E>, ok_message: &'static str, ) where E: Display, { apply_feedback_result(setter, result.map(|_| ok_message)); } fn open_hermes_page_ai_drawer() { let Some(win) = window() else { return; }; let Some(document) = win.document() else { return; }; if let Ok(Some(trigger)) = document.query_selector("[data-mnote-action=\"open-page-ai\"]") { if let Some(button) = trigger.dyn_ref::() { button.click(); } } } fn request_ai_edit_bridge( editor: TiptapEditorHandle, action: &'static str, document_id: ReadSignal>, workspace_id: ReadSignal>, selected_block_index: Option, selected_block_id: Option, selection_state: ReadSignal, set_ai_bridge_state: WriteSignal, set_ai_bridge_message: WriteSignal, set_dirty_count: WriteSignal, set_html_output: WriteSignal, set_document_json: WriteSignal, set_json_output: WriteSignal, title: ReadSignal, set_command_feedback: WriteSignal, ) { let _ = ( editor, action, document_id, workspace_id, selected_block_index, selected_block_id, selection_state, set_dirty_count, set_html_output, set_document_json, set_json_output, title, ); let _ = ( set_ai_bridge_state, set_ai_bridge_message, set_command_feedback, ); open_hermes_page_ai_drawer(); } fn normalize_layout_density(value: Option) -> String { match value.as_deref() { Some("compact") => "compact".to_string(), Some("spacious") => "spacious".to_string(), _ => "normal".to_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_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() .or_else(current_document_id); let initial_workspace_id = mount_options .workspace_id .clone() .or_else(current_workspace_id); let persisted_identity = persisted_mindmap_object_identity( mount_options.standalone_object.as_ref(), ) .unwrap_or_else(|| { resolve_persisted_document_identity( initial_document_id.clone(), initial_workspace_id.clone(), ) }); let persisted = load_persisted_document(&persisted_identity); let has_explicit_bootstrap_content = mount_options.html.is_some() || mount_options.content.is_some(); let restored_from_storage = persisted.is_some() && !has_explicit_bootstrap_content; let restored_html = if restored_from_storage { persisted .as_ref() .and_then(|document| document.html.clone()) } else { None }; let initial_title_value = mount_options .title .clone() .or_else(|| { restored_from_storage .then(|| persisted.as_ref().map(|document| document.title.clone())) .flatten() }) .unwrap_or_else(default_title); let initial_editor_content = mount_options .html .clone() .map(TiptapContent::html) .or_else(|| mount_options.content.clone().map(TiptapContent::json)) .or_else(|| { if restored_from_storage { persisted.as_ref().map(|document| { document .html .clone() .map(TiptapContent::html) .unwrap_or_else(|| TiptapContent::json(document.content.clone())) }) } else { None } }) .unwrap_or_else(initial_content); let (document_id, set_document_id) = signal(initial_document_id); let (workspace_id, set_workspace_id) = signal(initial_workspace_id); let (title, set_title) = signal(initial_title_value); let (read_only, set_read_only) = signal(mount_options.read_only.unwrap_or(false)); let (revision, set_revision) = signal(mount_options.revision); let (conflict_detection_key, set_conflict_detection_key) = signal(mount_options.conflict_detection_key.clone()); let (html_output, set_html_output) = signal(String::new()); let (document_json, set_document_json) = signal(mount_options.content.clone().unwrap_or_else(|| { if restored_from_storage { persisted .as_ref() .map(|document| document.content.clone()) .unwrap_or_else(|| json!({"type": "doc", "content": []})) } else { json!({"type": "doc", "content": []}) } })); let (json_output, set_json_output) = signal(String::new()); let (selection_state, set_selection_state) = signal(TiptapSelectionState::default()); let (dirty_count, set_dirty_count) = signal(0_u32); let (editor_focused, set_editor_focused) = signal(false); let (text_selection_active, set_text_selection_active) = signal(false); let (table_toolbar_open, set_table_toolbar_open) = signal(false); let (table_options_open, set_table_options_open) = signal(false); let (table_overlay_anchor, set_table_overlay_anchor) = signal(None::); let (table_selection_overlay, set_table_selection_overlay) = signal(None::); let (floating_toolbar_anchor, set_floating_toolbar_anchor) = signal(None::); let (image_toolbar_anchor, set_image_toolbar_anchor) = signal(None::); let (locked_toolbar_anchor, set_locked_toolbar_anchor) = signal(None::); let (slash_open, set_slash_open) = signal(false); let (slash_index, set_slash_index) = signal(0_usize); let (slash_query, set_slash_query) = signal(String::new()); let (turn_into_open, set_turn_into_open) = signal(false); let (color_menu_open, set_color_menu_open) = signal(false); let (more_menu_open, set_more_menu_open) = signal(false); let (hovered_block, set_hovered_block) = signal(None::); let (block_keyboard_mode, set_block_keyboard_mode) = signal(false); let (block_menu_anchor, set_block_menu_anchor) = signal(None::); let (block_menu_open, set_block_menu_open) = signal(false); let (block_turn_into_open, set_block_turn_into_open) = signal(false); let (block_folded_title_open, set_block_folded_title_open) = signal(false); let (_ai_bridge_state, set_ai_bridge_state) = signal("idle".to_string()); let (_ai_bridge_message, set_ai_bridge_message) = signal("".to_string()); let (pending_drag, set_pending_drag) = signal(None::); let (dragging_block_index, set_dragging_block_index) = signal(None::); let (dragging_block_anchor, set_dragging_block_anchor) = signal(None::); let (drop_indicator, set_drop_indicator) = signal(None::); let (suppress_handle_click, set_suppress_handle_click) = signal(false); let (command_feedback, set_command_feedback) = signal("等待第一次编辑".to_string()); let initial_editable = mount_options.editable.unwrap_or(!read_only.get_untracked()); let (editor_editable, set_editor_editable) = signal(initial_editable); let initial_page_options = mount_options.page_options.clone(); let (wide_layout, set_wide_layout) = signal( initial_page_options .as_ref() .and_then(|opts| opts.wide_layout) .unwrap_or(false), ); let (small_text, set_small_text) = signal( initial_page_options .as_ref() .and_then(|opts| opts.small_text) .unwrap_or(false), ); let (layout_density, set_layout_density) = signal(normalize_layout_density( initial_page_options .as_ref() .and_then(|opts| opts.layout_density.clone()), )); let (show_heading_numbers, set_show_heading_numbers) = signal( initial_page_options .as_ref() .and_then(|opts| opts.show_heading_numbers) .unwrap_or(true), ); let (embed_default_block_id, set_embed_default_block_id) = signal( initial_page_options .as_ref() .and_then(|opts| opts.embed_default_block_id.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; Effect::new(move |_| { let Some(document) = window().and_then(|win| win.document()) else { return; }; let Some(body) = document.body() else { return; }; if block_menu_open.get() { let _ = body.style().set_property("overflow", "hidden"); } else { let _ = body.style().remove_property("overflow"); } }); } { Effect::new(move |_| { let Some(stage) = editor_stage_element() else { return; }; let height = stage.get_bounding_client_rect().height(); if height > 0.0 { dispatch_runtime_event( height_event_target.clone(), HEIGHT_EVENT, &HeightPayload { height }, ); } }); } { let block_menu_open = block_menu_open; let set_block_turn_into_open = set_block_turn_into_open; let set_block_folded_title_open = set_block_folded_title_open; Effect::new(move |_| { if !block_menu_open.get() { set_block_turn_into_open.set(false); set_block_folded_title_open.set(false); } }); } Effect::new({ let editor = editor; let set_document_id = set_document_id; let set_workspace_id = set_workspace_id; let set_title = set_title; let set_read_only = set_read_only; let set_revision = set_revision; let set_conflict_detection_key = set_conflict_detection_key; let set_dirty_count = set_dirty_count; let set_html_output = set_html_output; let set_document_json = set_document_json; let set_json_output = set_json_output; let set_command_feedback = set_command_feedback; let set_editor_editable = set_editor_editable; let set_wide_layout = set_wide_layout; let set_small_text = set_small_text; let set_layout_density = set_layout_density; 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::::wrap(Box::new(move |event: Event| { let Some(custom_event) = event.dyn_ref::() else { return; }; let detail = custom_event.detail(); let Ok(envelope) = serde_wasm_bindgen::from_value::(detail) else { return; }; if envelope.protocol.as_deref() != Some(PROTOCOL) { return; } let Some(command_kind) = HostCommandKind::from_payload( envelope.payload.as_ref().unwrap_or(&HostCommandPayload { command: None, document_id: None, workspace_id: None, title: None, content: None, editable: None, page_options: None, block_id: None, block_index: None, text: None, reference_document_id: None, reference_block_id: None, current_block_id: None, selection: None, revision: None, conflict_detection_key: None, read_only: None, }), ) else { return; }; let Some(payload) = envelope.payload else { return; }; match command_kind { HostCommandKind::Undo => { let _ = editor.undo(); } HostCommandKind::Redo => { let _ = editor.redo(); } HostCommandKind::ReplaceContent | HostCommandKind::Bootstrap => { apply_host_document_payload( editor, HostDocumentPayload { document_id: payload.document_id, workspace_id: payload.workspace_id, title: payload.title, content: payload.content, revision: payload.revision, conflict_detection_key: payload.conflict_detection_key, read_only: payload .read_only .or(payload.editable.map(|editable| !editable)), }, set_document_id, set_workspace_id, set_title, set_read_only, set_revision, set_conflict_detection_key, set_dirty_count, set_html_output, set_document_json, set_json_output, set_command_feedback, ); } HostCommandKind::SetEditable => { let editable = payload.editable.unwrap_or(true); set_editor_editable.set(editable); set_read_only.set(!editable); 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 { if let Some(value) = page_options.wide_layout { set_wide_layout.set(value); } if let Some(value) = page_options.small_text { set_small_text.set(value); } set_layout_density .set(normalize_layout_density(page_options.layout_density)); if let Some(value) = page_options.show_heading_numbers { set_show_heading_numbers.set(value); } set_embed_default_block_id.set(page_options.embed_default_block_id); set_command_feedback.set("页面布局选项已同步".to_string()); } } HostCommandKind::Focus => { let _ = editor.focus(); } HostCommandKind::RequestCurrentBlockId => { let current_block = current_block_info_from_index( hovered_block.get_untracked().map(|block| block.index), ); 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 => { let reference_text = match ( payload.reference_document_id.or(payload.document_id), payload.reference_block_id.or(payload.block_id), ) { (Some(document_id), Some(block_id)) => { format!("[[引用 {}:{}]]", document_id, block_id) } (Some(document_id), None) => format!("[[引用 {}]]", document_id), _ => "[[引用]]".to_string(), }; let node = if matches!(command_kind, HostCommandKind::InsertEmbedReference) { json!({ "type": "blockquote", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": reference_text } ] } ] }) } else { json!({ "type": "paragraph", "content": [ { "type": "text", "text": reference_text } ] }) }; let _ = editor.insert_content(TiptapContent::json(node), None); } } })); if let Some(document) = window().and_then(|win| win.document()) { let listener_ref = command_listener.as_ref().unchecked_ref(); let _ = document.add_event_listener_with_callback(COMMAND_EVENT, listener_ref); if let Some((mount_id, _, _)) = runtime_mount_context() { let target: EventTarget = document.into(); register_runtime_listener(mount_id, target, command_listener); } } // ── Block-delta listener(Phase B:AI 写入增量通道) ── let delta_editor = editor; let delta_set_document_json = set_document_json; let delta_set_json_output = set_json_output; let delta_set_dirty_count = set_dirty_count; let delta_set_html_output = set_html_output; let delta_listener = Closure::::wrap(Box::new(move |event: Event| { let Some(custom_event) = event.dyn_ref::() else { return; }; let detail = custom_event.detail(); let Ok(delta): Result = serde_wasm_bindgen::from_value(detail) else { return; }; let Some(operations) = delta.get("operations").and_then(Value::as_array) else { return; }; if operations.is_empty() { return; } // Editor instance maybe unavailable while loading let Some(instance) = delta_editor.instance_untracked() else { return; }; // Read current content let Ok(mut content) = instance.get_json() else { return; }; // Apply delta operations to the Tiptap JSON tree let changed = apply_block_delta_to_json(&mut content, operations); if !changed { return; } // Write back if instance .set_content(TiptapContent::json(content.clone())) .is_ok() { // Update reactive state let html = instance.get_html().unwrap_or_default(); let json_text = serde_json::to_string(&content).unwrap_or_default(); delta_set_dirty_count.update(|c| *c += 1); delta_set_html_output.set(html); delta_set_json_output.set(json_text); delta_set_document_json.set(content); } })); if let Some(document) = window().and_then(|win| win.document()) { let delta_ref = delta_listener.as_ref().unchecked_ref(); let _ = document.add_event_listener_with_callback(BLOCK_DELTA_EVENT, delta_ref); if let Some((mount_id, _, _)) = runtime_mount_context() { let target: EventTarget = document.into(); register_runtime_listener(mount_id, target, delta_listener); } } } }); { let turn_into_open = turn_into_open; let color_menu_open = color_menu_open; let more_menu_open = more_menu_open; let set_locked_toolbar_anchor = set_locked_toolbar_anchor; Effect::new(move |_| { if !(turn_into_open.get() || color_menu_open.get() || more_menu_open.get()) { set_locked_toolbar_anchor.set(None); } }); } { let editor_focused = editor_focused; let text_selection_active = text_selection_active; let slash_open = slash_open; let block_menu_open = block_menu_open; let turn_into_open = turn_into_open; let color_menu_open = color_menu_open; let more_menu_open = more_menu_open; let set_turn_into_open = set_turn_into_open; let set_color_menu_open = set_color_menu_open; let set_more_menu_open = set_more_menu_open; Effect::new(move |_| { if should_auto_close_toolbar_overlays( editor_focused.get(), text_selection_active.get(), turn_into_open.get_untracked(), color_menu_open.get_untracked(), more_menu_open.get_untracked(), slash_open.get(), block_menu_open.get(), ) { set_turn_into_open.set(false); set_color_menu_open.set(false); set_more_menu_open.set(false); } }); } { let set_editor_focused = set_editor_focused; let set_text_selection_active = set_text_selection_active; let set_table_toolbar_open = set_table_toolbar_open; let set_table_options_open = set_table_options_open; let set_table_overlay_anchor = set_table_overlay_anchor; let set_table_selection_overlay = set_table_selection_overlay; let set_floating_toolbar_anchor = set_floating_toolbar_anchor; let set_image_toolbar_anchor = set_image_toolbar_anchor; let set_slash_open = set_slash_open; let set_turn_into_open = set_turn_into_open; let set_hovered_block = set_hovered_block; let set_block_menu_anchor = set_block_menu_anchor; let set_block_menu_open = set_block_menu_open; let set_pending_drag = set_pending_drag; let set_dragging_block_index = set_dragging_block_index; let set_dragging_block_anchor = set_dragging_block_anchor; let set_drop_indicator = set_drop_indicator; let set_command_feedback = set_command_feedback; let set_suppress_handle_click = set_suppress_handle_click; let pending_drag = pending_drag; let dragging_block_index = dragging_block_index; let mouseup_handle = window_event_listener(ev::mouseup, move |event: MouseEvent| { if let Some(source_index) = dragging_block_index.try_get_untracked().flatten() { 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); } } } else { let _ = set_command_feedback.try_set("块拖拽已取消".to_string()); } let _ = set_dragging_block_index.try_set(None); let _ = set_dragging_block_anchor.try_set(None); let _ = set_drop_indicator.try_set(None); let _ = set_pending_drag.try_set(None); let _ = set_block_menu_open.try_set(false); let _ = set_block_menu_anchor.try_set(None); let _ = set_hovered_block.try_set(None); let _ = set_suppress_handle_click.try_set(true); } else if pending_drag.try_get_untracked().flatten().is_some() { let _ = set_pending_drag.try_set(None); } if let Some(target) = event.target() { if let Some(element) = target_element(target) { if element .closest(&format!("{HANDLE_SHELL_SELECTOR}, .floating-toolbar, .image-floating-toolbar, .table-toolbar, .table-controls")) .ok() .flatten() .is_some() { return; } let in_image = element .closest(".editor-surface .ProseMirror img[src]") .ok() .flatten() .is_some(); if !in_image { let _ = set_image_toolbar_anchor.try_set(None); } let _ = set_table_options_open.try_set(false); let table = element .closest(".editor-surface .ProseMirror table") .ok() .flatten(); let in_table = table.is_some(); let _ = set_table_toolbar_open.try_set(in_table); if let Some(table) = table { let _ = set_table_overlay_anchor .try_set(table_overlay_anchor_from_table(&table)); } else { let _ = set_table_overlay_anchor.try_set(None); let _ = set_table_selection_overlay.try_set(None); } } } let _ = try_sync_editor_overlay_state( set_editor_focused, set_text_selection_active, set_floating_toolbar_anchor, set_slash_open, set_turn_into_open, set_hovered_block, set_block_menu_anchor, set_block_menu_open, ); }); let keyup_handle = window_event_listener(ev::keyup, move |_| { let _ = try_sync_editor_overlay_state( set_editor_focused, set_text_selection_active, set_floating_toolbar_anchor, set_slash_open, set_turn_into_open, set_hovered_block, set_block_menu_anchor, set_block_menu_open, ); }); on_cleanup(move || { drop(mouseup_handle); drop(keyup_handle); }); } { let set_editor_focused = set_editor_focused; let set_slash_open = set_slash_open; let set_turn_into_open = set_turn_into_open; let set_hovered_block = set_hovered_block; let set_block_menu_anchor = set_block_menu_anchor; let set_block_menu_open = set_block_menu_open; let set_pending_drag = set_pending_drag; let set_dragging_block_index = set_dragging_block_index; let set_dragging_block_anchor = set_dragging_block_anchor; let set_drop_indicator = set_drop_indicator; let set_command_feedback = set_command_feedback; let pending_drag = pending_drag; let dragging_block_index = dragging_block_index; let dragging_block_anchor = dragging_block_anchor; let mousemove_handle = window_event_listener(ev::mousemove, move |event: MouseEvent| { if dragging_block_index.try_get_untracked().flatten().is_some() { let _ = set_editor_focused.try_set(true); let _ = set_drop_indicator.try_set(drop_indicator_from_point( event.client_x(), event.client_y(), )); return; } let Some(pending) = pending_drag.try_get_untracked().flatten() else { return; }; if event.buttons() & 1 == 0 { let _ = set_pending_drag.try_set(None); return; } let dx = (event.client_x() - pending.start_x).abs(); let dy = (event.client_y() - pending.start_y).abs(); if dx < 4 && dy < 4 { return; } let _ = set_editor_focused.try_set(true); let _ = set_slash_open.try_set(false); let _ = set_turn_into_open.try_set(false); let _ = set_block_menu_open.try_set(false); let _ = set_block_menu_anchor.try_set(None); let _ = set_hovered_block.try_set(Some(pending.anchor.clone())); let _ = set_dragging_block_index.try_set(Some(pending.index)); let next_anchor = dragging_block_anchor .try_get_untracked() .flatten() .unwrap_or_else(|| pending.anchor.clone()); let _ = set_dragging_block_anchor.try_set(Some(next_anchor)); let _ = set_pending_drag.try_set(None); let _ = set_drop_indicator.try_set(drop_indicator_from_point( event.client_x(), event.client_y(), )); let _ = set_command_feedback.try_set(format!("开始拖拽:{}", pending.anchor.label)); }); on_cleanup(move || drop(mousemove_handle)); } { let editor = editor; let document_id = document_id; let workspace_id = workspace_id; let title = title; let set_dirty_count = set_dirty_count; let set_html_output = set_html_output; let set_document_json = set_document_json; let set_json_output = set_json_output; let hovered_block = hovered_block; let set_hovered_block = set_hovered_block; let block_keyboard_mode = block_keyboard_mode; let set_block_keyboard_mode = set_block_keyboard_mode; let set_slash_open = set_slash_open; let slash_open = slash_open; let slash_index = slash_index; let set_slash_index = set_slash_index; let slash_query = slash_query; let set_slash_query = set_slash_query; let turn_into_open = turn_into_open; let set_turn_into_open = set_turn_into_open; let color_menu_open = color_menu_open; let set_color_menu_open = set_color_menu_open; let more_menu_open = more_menu_open; let set_more_menu_open = set_more_menu_open; let set_locked_toolbar_anchor = set_locked_toolbar_anchor; let set_text_selection_active = set_text_selection_active; let set_floating_toolbar_anchor = set_floating_toolbar_anchor; let image_toolbar_anchor = image_toolbar_anchor; let set_image_toolbar_anchor = set_image_toolbar_anchor; let table_toolbar_open = table_toolbar_open; let set_table_toolbar_open = set_table_toolbar_open; let table_options_open = table_options_open; let set_table_options_open = set_table_options_open; let set_table_overlay_anchor = set_table_overlay_anchor; let set_table_selection_overlay = set_table_selection_overlay; let set_block_menu_open = set_block_menu_open; let block_menu_open = block_menu_open; let set_block_menu_anchor = set_block_menu_anchor; 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| { if !active_editor_stage() || event.ctrl_key() || event.meta_key() || event.alt_key() || !matches!(event.key().as_str(), "Backspace" | "Delete") || !editor_runtime::attachment_links::is_local_attachment_link_selected() { return; } event.prevent_default(); event.stop_immediate_propagation(); match editor_runtime::history_safe_commands::delete_selected_attachment_link_with_history( &editor, ) { Ok(()) => { sync_persisted_editor_command( editor, &runtime_persisted_identity(document_id, workspace_id), set_dirty_count, set_html_output, set_document_json, set_json_output, title, set_command_feedback, "已删除附件引用", ); let _ = set_hovered_block.try_set(None); let _ = set_block_menu_open.try_set(false); let _ = set_block_menu_anchor.try_set(None); } Err(err) => { let _ = set_command_feedback.try_set(format!("删除附件引用失败:{err}")); } } }, ) as Box)); if let Some(win) = window() { let _ = win.add_event_listener_with_callback_and_bool( "keydown", keydown_capture_closure.as_ref().unchecked_ref(), true, ); } on_cleanup(move || { if let Some(win) = window() { let _ = win.remove_event_listener_with_callback_and_bool( "keydown", keydown_capture_closure.as_ref().unchecked_ref(), true, ); } drop(keydown_capture_closure); }); let keydown_handle = window_event_listener(ev::keydown, move |event| { let focused = active_editor_stage(); if !focused { 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")); if undo_shortcut || redo_shortcut { event.prevent_default(); if redo_shortcut { let _ = editor.redo(); } else { let _ = editor.undo(); } schedule_editor_focus(editor); return; } } return; } if (event.ctrl_key() || event.meta_key()) && event.shift_key() && event.key().eq_ignore_ascii_case("u") { event.prevent_default(); let Some(block) = hovered_block_from_selection() .or_else(|| hovered_block.try_get_untracked().flatten()) .or_else(|| block_state_from_index(0)) else { let _ = set_command_feedback.try_set("没有可进入布局选中态的当前块".to_string()); return; }; let _ = set_hovered_block.try_set(Some(block.clone())); let _ = set_block_keyboard_mode.try_set(true); let _ = set_slash_open.try_set(false); let _ = set_slash_query.try_set(String::new()); let _ = set_turn_into_open.try_set(false); let _ = set_block_menu_open.try_set(false); let _ = set_block_menu_anchor.try_set(None); let _ = set_command_feedback.try_set(format!("已进入块布局选中态:{}", block.label)); return; } if (event.ctrl_key() || event.meta_key()) && event.shift_key() && !event.alt_key() { let action = match event.key().as_str() { "1" | "!" => Some(SlashActionKind::Heading1), "2" | "@" => Some(SlashActionKind::Heading2), "3" | "#" => Some(SlashActionKind::Heading3), "5" | "%" => Some(SlashActionKind::Todo), "6" | "^" => Some(SlashActionKind::BulletList), "7" | "&" => Some(SlashActionKind::OrderedList), _ => None, }; if let Some(action) = action { event.prevent_default(); let result = run_slash_action(editor, action); let message = match result { Ok(message) => { sync_persisted_editor_command( editor, &runtime_persisted_identity(document_id, workspace_id), set_dirty_count, set_html_output, set_document_json, set_json_output, title, set_command_feedback, message, ); None } Err(err) => Some(err), }; if let Some(message) = message { let _ = set_command_feedback.try_set(message); } let _ = set_slash_open.try_set(false); let _ = set_slash_query.try_set(String::new()); let _ = set_turn_into_open.try_set(false); let _ = set_block_menu_open.try_set(false); let _ = set_block_menu_anchor.try_set(None); return; } } if event.key() == "Escape" { event.prevent_default(); if close_editor_floating_overlays_if_escape( slash_open, turn_into_open, color_menu_open, more_menu_open, image_toolbar_anchor, table_toolbar_open, table_options_open, block_menu_open, set_slash_open, set_slash_index, set_slash_query, set_turn_into_open, set_color_menu_open, set_more_menu_open, set_locked_toolbar_anchor, set_text_selection_active, set_floating_toolbar_anchor, set_image_toolbar_anchor, set_table_toolbar_open, set_table_options_open, set_table_overlay_anchor, set_table_selection_overlay, set_block_menu_open, set_block_menu_anchor, set_block_turn_into_open, set_block_folded_title_open, ) { let _ = set_block_keyboard_mode.try_set(false); return; } if let Some(block) = hovered_block_from_selection() .or_else(|| hovered_block.try_get_untracked().flatten()) .or_else(|| block_state_from_index(0)) { let _ = set_hovered_block.try_set(Some(block.clone())); let _ = set_block_keyboard_mode.try_set(true); let _ = set_command_feedback .try_set(format!("已选中块:{},按 a/b 可在上/下插入", block.label)); } return; } if block_keyboard_mode.try_get_untracked().unwrap_or(false) && !event.ctrl_key() && !event.meta_key() && !event.alt_key() && matches!(event.key().as_str(), "a" | "A" | "b" | "B") { event.prevent_default(); let before = matches!(event.key().as_str(), "a" | "A"); let Some(block) = hovered_block .try_get_untracked() .flatten() .or_else(|| hovered_block_from_selection()) .or_else(|| block_state_from_index(0)) else { let _ = set_command_feedback.try_set("没有可插入的当前块".to_string()); return; }; match insert_editor_paragraph_relative_to_block(editor, block.index, before) { Ok(new_index) => { sync_persisted_editor_command( editor, &runtime_persisted_identity(document_id, workspace_id), set_dirty_count, set_html_output, set_document_json, set_json_output, title, set_command_feedback, if before { "已通过 Esc,a 在上方插入新块" } else { "已通过 Esc,b 在下方插入新块" }, ); let _ = set_hovered_block.try_set(block_state_from_index(new_index)); let _ = set_block_keyboard_mode.try_set(false); } Err(err) => { let _ = set_command_feedback.try_set(err); } } return; } if event.key() == "/" && !event.ctrl_key() && !event.meta_key() && !event.alt_key() { event.prevent_default(); open_slash_menu_overlay( set_slash_open, set_slash_index, set_slash_query, set_turn_into_open, set_color_menu_open, set_more_menu_open, set_locked_toolbar_anchor, set_text_selection_active, set_floating_toolbar_anchor, set_image_toolbar_anchor, set_table_toolbar_open, set_table_options_open, set_table_overlay_anchor, set_table_selection_overlay, set_block_menu_open, set_block_menu_anchor, set_block_turn_into_open, set_block_folded_title_open, ); let _ = set_block_keyboard_mode.try_set(false); let _ = set_command_feedback.try_set("Slash 菜单已打开".to_string()); return; } if !slash_open.try_get_untracked().unwrap_or(false) { return; } match event.key().as_str() { "ArrowDown" => { event.prevent_default(); let next = (slash_index.try_get_untracked().unwrap_or(0) + 1) % SLASH_ACTIONS.len(); let _ = set_slash_index.try_set(next); } "ArrowUp" => { event.prevent_default(); let current = slash_index.try_get_untracked().unwrap_or(0); let next = if current == 0 { SLASH_ACTIONS.len() - 1 } else { current - 1 }; let _ = set_slash_index.try_set(next); } "Backspace" => { if !slash_query .try_get_untracked() .unwrap_or_default() .is_empty() { event.prevent_default(); let _ = set_slash_query.try_update(|query| { query.pop(); }); let _ = set_slash_index.try_set(0); } } "Enter" => { event.prevent_default(); let action = SLASH_ACTIONS[slash_index.try_get_untracked().unwrap_or(0)].kind; match run_slash_action(editor, action) { Ok(message) => { let _ = set_command_feedback.try_set(message.to_string()); } Err(message) => { let _ = set_command_feedback.try_set(message); } } let _ = set_slash_open.try_set(false); } _ => { let key = event.key(); if key.chars().count() == 1 && !event.ctrl_key() && !event.meta_key() && !event.alt_key() { event.prevent_default(); let _ = set_slash_query.try_update(|query| query.push_str(&key)); let _ = set_slash_index.try_set(0); } } } }); on_cleanup(move || drop(keydown_handle)); } { let click_handle = window_event_listener(ev::click, move |event: MouseEvent| { let Some(element) = event.target().and_then(target_element) else { return; }; let Some(anchor) = element.closest("a.mnote-page-block-link").ok().flatten() else { return; }; let Some(href) = anchor .get_attribute("href") .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty() && value != "#") else { return; }; event.prevent_default(); event.stop_propagation(); if let Some(win) = window() { let _ = win.location().assign(&href); } }); on_cleanup(move || drop(click_handle)); } { schedule_scroll_mnote_block_anchor_from_hash(); let hashchange_handle = window_event_listener(ev::hashchange, move |_| { schedule_scroll_mnote_block_anchor_from_hash(); }); on_cleanup(move || drop(hashchange_handle)); } let has_task_schema = move || { let snapshot = json_output.get(); snapshot.contains("\"taskList\"") && snapshot.contains("\"taskItem\"") }; let selection_text = move || selection_summary(&selection_state.get()); let is_embedded = is_embedded_mode(); let on_link_click = { let editor = editor; let selection_state = selection_state; let set_command_feedback = set_command_feedback; move |_| { if selection_state.get().link { apply_feedback_result( set_command_feedback, editor.unset_link().map(|_| "已移除链接"), ); return; } let href = window() .and_then(|win| win.prompt_with_message("输入链接地址").ok().flatten()) .filter(|value| !value.trim().is_empty()); match href { Some(value) => apply_feedback_result( set_command_feedback, editor .set_link(TiptapLinkResource { href: value, target: Some("_blank".into()), rel: Some("noopener noreferrer".into()), class: None, }) .map(|_| "已插入链接"), ), None => set_command_feedback.set("链接操作已取消".to_string()), } } }; let slash_menu_runtime = SlashMenuRuntimeContext { change_event_target: slash_change_event_target.clone(), dispatch_runtime_state: std::sync::Arc::new(move |target: &EventTarget| { 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(), ); }), }; let slash_menu_props = SlashMenuViewProps { editor, signals: SlashMenuSignals { slash_open, set_slash_open, slash_index, slash_query, dirty_count, set_dirty_count, document_json: set_document_json, html_output: set_html_output, json_output: set_json_output, document_id, workspace_id, title, editor_focused, hovered_block, turn_into_open, color_menu_open, more_menu_open, revision, conflict_detection_key, read_only, command_feedback: set_command_feedback, }, runtime: slash_menu_runtime, anchor_style: std::sync::Arc::new(slash_menu_anchor_style), }; view! {
{move || { if is_embedded { ().into_any() } else { view! {
"P0 / Tiptap-like 主编辑器体验"

"把 Spike 收敛成能检验的最小产品页壳"

"这里继续只做 " "8123" " 的 Leptos + Tiptap P0 体验验证,不挪动正式 " "/documents/[id]" " 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。"

"变更计数" {move || dirty_count.get().to_string()}
"Todo Schema" {move || if has_task_schema() { "ready" } else { "pending" }}
"Selection" {selection_text}
}.into_any() } }}
{move || { if is_embedded { ().into_any() } else { view! { <>
"空间 / 主文档 /" "Editor Baseline Reset"
"反馈:" {move || command_feedback.get()}
set_command_feedback.set("标题已写入本地草稿".to_string()), Err(err) => set_command_feedback.set(format!("标题保存失败:{err}")), } } placeholder="给这篇页面起个标题" aria-label="Spike 页面标题" />
"输入 “/” 打开真正的最小 slash 菜单" "选中文本时会出现最小浮动工具条" "Todo 现在走 taskList / taskItem,而不是占位 HTML" "把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排" "本页会把 JSON 草稿写入 localStorage,刷新后不丢结构"
}.into_any() } }}
{ sync_persisted_editor_command( editor, &runtime_persisted_identity(document_id, workspace_id), set_dirty_count, set_html_output, set_document_json, set_json_output, title, set_command_feedback, "已通过块手柄拖拽重排", ); } Err(err) => { set_command_feedback.set(err); } } set_dragging_block_index.set(None); set_dragging_block_anchor.set(None); set_drop_indicator.set(None); set_block_menu_open.set(false); set_block_menu_anchor.set(None); set_hovered_block.set(None); } > {table_toolbar_view( editor, table_toolbar_open, set_table_toolbar_open, table_options_open, set_table_options_open, table_overlay_anchor, set_table_overlay_anchor, table_selection_overlay, set_table_selection_overlay, slash_open, block_menu_open, dragging_block_index, document_json, set_document_json, set_dirty_count, set_html_output, set_json_output, title, document_id, workspace_id, set_command_feedback, )} {move || { let Some(anchor) = image_toolbar_anchor.get() else { return ().into_any(); }; if slash_open.get() || block_menu_open.get() || dragging_block_index.get().is_some() { return ().into_any(); } view! {
}.into_any() }} {move || { let toolbar_locked = toolbar_overlay_locked( turn_into_open.get(), color_menu_open.get(), more_menu_open.get(), ); let toolbar_anchor = if toolbar_locked { locked_toolbar_anchor .get() .or_else(|| floating_toolbar_anchor.get()) } else { floating_toolbar_anchor.get() }; if let Some(anchor) = toolbar_anchor { if should_auto_close_toolbar_overlays( editor_focused.get(), text_selection_active.get(), turn_into_open.get(), color_menu_open.get(), more_menu_open.get(), slash_open.get(), block_menu_open.get(), ) || dragging_block_index.get().is_some() { return ().into_any(); } view! {
{SLASH_ACTIONS.iter().map(|action| { let kind = action.kind; let label = action.label; let description = action.description; let testid = format!("turn-into-{}", action.id); view! { } }).collect_view()}
"文字颜色"
{TOOLBAR_TEXT_COLORS.iter().map(|option| { let value = option.value; let label = option.label; let testid = format!("toolbar-text-color-{}", option.id); view! { } }).collect_view()}
"背景颜色"
{TOOLBAR_HIGHLIGHT_COLORS.iter().map(|option| { let value = option.value; let label = option.label; let testid = format!("toolbar-highlight-color-{}", option.id); view! { } }).collect_view()}
"对齐方式"
{TOOLBAR_ALIGN_OPTIONS.iter().map(|option| { let label = option.label; let alignment = option.alignment; let testid = format!("toolbar-align-{}", option.id); let active = match option.alignment { TiptapTextAlign::Left => selection_state.get().align_left, TiptapTextAlign::Center => selection_state.get().align_center, TiptapTextAlign::Right => selection_state.get().align_right, TiptapTextAlign::Justify => selection_state.get().align_justify, }; view! { } }).collect_view()}
}.into_any() } else { ().into_any() } }} {move || { if let Some(indicator) = drop_indicator.get() { let drop_indicator_left = editor_stage_element() .map(|stage| { let stage_rect = stage.get_bounding_client_rect(); format!("{}px", content_column_left(stage_rect.width())) }) .unwrap_or_else(|| "56px".to_string()); let drop_indicator_right = editor_stage_element() .map(|stage| { let stage_rect = stage.get_bounding_client_rect(); let content_left = content_column_left(stage_rect.width()); format!("{}px", content_left) }) .unwrap_or_else(|| "56px".to_string()); view! {
}.into_any() } else { ().into_any() } }} {move || { if block_keyboard_mode.get() && editor_focused.get() && !text_selection_active.get() && !slash_open.get() && !block_menu_open.get() { if let Some(block) = hovered_block.get().or_else(hovered_block_from_selection) { let outline_top = format!("{}px", (block.top - 2.0).max(0.0)); let outline_height = format!("{}px", (block.height + 4.0).max(30.0)); return view! {
}.into_any(); } } ().into_any() }} {move || { let active_block = if block_menu_open.get() { block_menu_anchor.get() } else if dragging_block_index.get().is_some() { dragging_block_anchor.get() } else { hovered_block.get().or_else(|| { if block_keyboard_mode.get() && editor_focused.get() && !text_selection_active.get() && !slash_open.get() { hovered_block_from_selection() } else { None } }) }; if let Some(block) = active_block { let dragging_index = dragging_block_index.get(); let is_dragging = dragging_index .map(|active| active == block.index) .unwrap_or(false); if !editor_focused.get() || text_selection_active.get() || slash_open.get() || (dragging_index.is_some() && !is_dragging) { return ().into_any(); } let anchor_top = format!("{}px", block_menu_overlay::hover_anchor_top(&block)); let anchor_transform = block_menu_overlay::hover_anchor_transform(&block); let handle_left = editor_stage_element() .map(|stage| { let stage_rect = stage.get_bounding_client_rect(); format!("{}px", handle_lane_left(stage_rect.width())) }) .unwrap_or_else(|| "12px".to_string()); let block_index = block.index; let block_label = block.label.clone(); let click_block_label = block_label.clone(); let duplicate_block_label = block_label.clone(); let delete_block_label = block_label.clone(); let insert_before_block_label = block_label.clone(); let insert_after_block_label = block_label.clone(); let click_anchor_block = block.clone(); let drag_anchor_block = block.clone(); view! {
{move || { if block_menu_open.get() && block_menu_anchor .get() .map(|current| current.index == block_index) .unwrap_or(false) { let current_duplicate_label = duplicate_block_label.clone(); let current_delete_label = delete_block_label.clone(); let menu_layout = editor_stage_element().and_then(|stage| { let rect = stage.get_bounding_client_rect(); block_menu_overlay::block_menu_layout(&block, rect.width(), rect.height(), rect.top(), rect.left()) }).unwrap_or(BlockMenuLayout { max_height: 360.0, open_upward: false, top: 80.0, left: 80.0, }); let menu_top = format!("{}px", menu_layout.top); let menu_bottom = "auto"; let current_block_is_page = top_level_block_is_page(editor, block_index); let page_current_marker = if current_block_is_page { "✓" } else { "Ctrl+Shift+9" }; view! {
{move || { let duplicate_feedback_label = current_duplicate_label.clone(); let delete_feedback_label = current_delete_label.clone(); view! { <>
{move || { if block_turn_into_open.get() { view! {
{SLASH_ACTIONS .iter() .filter(|action| !matches!(action.kind, SlashActionKind::Divider | SlashActionKind::SimpleTable | SlashActionKind::Image | SlashActionKind::UploadAttachment | SlashActionKind::Toc | SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi)) .map(|action| { let kind = action.kind; let label = action.label; let icon = action.icon; let is_current = top_level_block_matches_action(editor, block_index, action.kind); let shortcut = if is_current { "✓" } else { match action.kind { SlashActionKind::Paragraph => "Ctrl+Alt+0", SlashActionKind::Todo => "Ctrl+Shift+5", SlashActionKind::Heading1 => "Ctrl+Shift+1", SlashActionKind::Heading2 => "Ctrl+Shift+2", SlashActionKind::Heading3 => "Ctrl+Shift+3", SlashActionKind::BulletList => "Ctrl+Shift+6", SlashActionKind::OrderedList => "Ctrl+Shift+7", SlashActionKind::CodeBlock => "Ctrl+Alt+-", SlashActionKind::Mindmap => "/dt", _ => "", } }; let testid = format!("block-transform-item-{}", action.id); let action_menu_label = "块".to_string(); view! { } }) .collect_view()} {move || { if block_folded_title_open.get() { view! {
{FOLDED_HEADING_ACTIONS .iter() .map(|folded| { let action = *folded; let testid = format!("block-transform-folded-heading-{}", action.id); view! { } }) .collect_view()}
}.into_any() } else { ().into_any() } }}
}.into_any() } else { ().into_any() } }} } }}
}.into_any() } else { ().into_any() } }}
}.into_any() } else { ().into_any() } }} {slash_menu_view(slash_menu_props)} { if restored_from_storage { set_command_feedback.set("编辑器已准备就绪,并已恢复本地草稿".to_string()); } else { set_command_feedback.set("编辑器已准备就绪,当前内容已写入本地草稿".to_string()); } } Err(err) => { set_command_feedback.set(format!("编辑器已准备就绪,但草稿初始化失败:{err}")); } } } on_change=move |_| { set_dirty_count.update(|count| *count += 1); let snapshot = sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output); 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(), &snapshot, Some(html_output.get_untracked()), ) { Ok(()) => { set_command_feedback.set("内容已变更,已同步观察值并写入本地草稿".to_string()); } Err(err) => { set_command_feedback.set(format!("内容已变更,但本地保存失败:{err}")); } } schedule_scroll_mnote_block_anchor_from_hash(); } on_selection_change=move |selection: TiptapSelectionState| { let selection_clone = selection.clone(); set_selection_state.set(selection_clone.clone()); sync_overlays_on_selection_change( turn_into_open, color_menu_open, more_menu_open, text_selection_active, set_text_selection_active, set_floating_toolbar_anchor, set_image_toolbar_anchor, set_turn_into_open, set_block_menu_open, set_block_menu_anchor, set_hovered_block, ); 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" attr:data-small-text=move || small_text.get().to_string() attr:data-layout-density=move || layout_density.get() attr:data-show-heading-numbers=move || show_heading_numbers.get().to_string() attr:data-embed-default-block-id=move || embed_default_block_id.get().unwrap_or_default() />
{move || { if is_embedded { ().into_any() } else { view! { }.into_any() } }}
{move || { if is_embedded { ().into_any() } else { view! {
"调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面" "展开 / 收起"

"Selection"

{selection_text}

"HTML"

{move || html_output.get()}

"JSON"

{move || json_output.get()}
}.into_any() } }}
} } pub fn standalone_main() { console_error_panic_hook::set_once(); mount_to_body(|| { view! { } }); }