use leptos::ev; use leptos::mount::{mount_to, mount_to_body, UnmountHandle}; use leptos::prelude::*; use leptos_dom::helpers::window_event_listener; use leptos_tiptap::{ TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditor, TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel, TiptapHighlightAttributes, TiptapImageResource, TiptapInsertContentOptions, TiptapLinkResource, TiptapMarkName, TiptapNodeName, TiptapRange, TiptapSchemaTarget, TiptapSelectionState, TiptapTextAlign, TiptapTocNodeAttrs, }; use send_wrapper::SendWrapper; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display}; use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue}; use wasm_bindgen_futures::{spawn_local, JsFuture}; use web_sys::{ window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement, HtmlInputElement, MouseEvent, Node, RequestInit, RequestMode, Response, WheelEvent, }; use editor_runtime::attachment_upload::dispatch_editor_upload_request; use editor_runtime::block_dnd::{ direct_block_from_element, direct_block_index_from_element, drop_indicator_from_block_rects, element_within_handle_shell, event_target_from_point, pointer_in_handle_corridor_geometry, top_level_block_index, HandleCorridorGeometry, }; use editor_runtime::block_menu_overlay; use editor_runtime::block_menu_document::{ collect_plain_text, mindmap_paragraph_node, paragraph_node, }; use editor_runtime::block_menu_legacy_html::{ duplicate_top_level_block_html, reorder_top_level_block_html, }; use editor_runtime::block_hover_state::{ BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState, }; use editor_runtime::command_sync::{ read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command, }; use editor_runtime::dom_events::{event_target_matches_selector, target_element}; use editor_runtime::dom_selection::{ runtime_block_id_from_index, selection_payload, selection_summary, SelectionPayload, }; use editor_runtime::editor_focus::{active_editor_stage, body_has_focus, schedule_editor_focus}; use editor_runtime::overlays::{ close_editor_floating_overlays_if_escape, open_block_menu_overlay, open_image_toolbar_overlay, open_slash_menu_overlay, current_table_overlay_anchor, sync_overlays_on_selection_change, table_col_aux_style, table_overlay_anchor_from_table, table_row_aux_style, toolbar_overlay_locked, try_sync_editor_overlay_state, FloatingToolbarAnchor, ImageToolbarAnchor, TableOverlayAnchor, TableSelectionKind, TableSelectionOverlayState, }; use editor_runtime::persistence::{ load_persisted_document, normalize_identity_value, persist_document_state, persisted_document_identity, PersistedDocumentIdentity, }; #[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"; const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell"; const CONTENT_COLUMN_MAX_WIDTH: f64 = 708.0; const CONTENT_COLUMN_HORIZONTAL_PADDING: f64 = 48.0; 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; const RUNTIME_NAME: &str = "8123-leptos-tiptap-runtime"; const RUNTIME_VERSION: &str = "1.1.0"; const PROTOCOL: &str = "mnote.leptos_tiptap.bridge.v1"; const EVENT_PREFIX: &str = "mnote:leptos-tiptap-spike"; const READY_EVENT: &str = "mnote:leptos-tiptap-spike:ready"; const CHANGE_EVENT: &str = "mnote:leptos-tiptap-spike:change"; const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state"; const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status"; const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection"; const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command"; const BLOCK_DELTA_EVENT: &str = "mnote:editor:block-delta"; const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height"; const MINDMAP_SHELL_ACTION_EVENT: &str = "mnote:mindmap-shell:action"; const MINDMAP_SHELL_PANEL_EVENT: &str = "mnote:mindmap-shell:panel"; const MINDMAP_SHELL_MINIMAP_EVENT: &str = "mnote:mindmap-shell:minimap"; const MINDMAP_SHELL_ZOOM_EVENT: &str = "mnote:mindmap-shell:zoom"; const MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT: &str = "mnote:mindmap-shell:toolbar-overflow"; 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, }, ]; const SPIKE_STYLE: &str = r#" :root { --tt-color-text: #111827; --tt-color-text-gray: #6b7280; --tt-color-text-brown: #8b5e3c; --tt-color-text-orange: #c96b1f; --tt-color-text-yellow: #b7791f; --tt-color-text-green: #18794e; --tt-color-text-blue: #1d4ed8; --tt-color-text-purple: #7c3aed; --tt-color-text-pink: #be185d; --tt-color-text-red: #b91c1c; --tt-color-highlight-yellow: #fef3c7; --tt-color-highlight-green: #dcfce7; --tt-color-highlight-blue: #dbeafe; --tt-color-highlight-purple: #ede9fe; --tt-color-highlight-pink: #fce7f3; --tt-color-highlight-red: #fee2e2; --tt-color-highlight-orange: #ffedd5; --tt-color-highlight-gray: #e5e7eb; } .app-shell { max-width: 1180px; margin: 0 auto; padding: 28px 20px 72px; } .app-shell-embedded { max-width: none; margin: 0; padding: 0; } .app-shell-embedded .hero-bar, .app-shell-embedded .title-input, .app-shell-embedded .editor-subcopy, .app-shell-embedded .footer-strip, .app-shell-embedded .debug-drawer, .app-shell-embedded .editor-crumbs:first-child, .app-shell-embedded .editor-crumbs:last-child { display: none; } .app-shell-embedded .editor-card { border: none; border-radius: 0; box-shadow: none; background: transparent; overflow: visible; } .app-shell-embedded .editor-topbar { display: none; } .app-shell-embedded .editor-stage { margin: 0; border: none; border-radius: 0; background: transparent; box-shadow: none; } .hero-bar { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; margin-bottom: 20px; } .hero-meta { display: grid; gap: 10px; } .hero-tag { display: inline-flex; align-items: center; gap: 8px; width: fit-content; padding: 6px 12px; border-radius: 999px; background: rgba(15, 118, 110, 0.12); color: var(--accent-strong); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; } .hero-title { margin: 0; font-size: 40px; line-height: 1.04; } .hero-copy { margin: 0; max-width: 680px; color: var(--muted); line-height: 1.75; } .hero-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; } .hero-chip { display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 16px; background: rgba(255, 255, 255, 0.92); color: var(--ink); font-size: 13px; box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); } .hero-chip strong { color: var(--accent-strong); } .editor-card { position: relative; border: 1px solid rgba(100, 116, 139, 0.18); border-radius: 30px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(254, 251, 246, 0.98) 100%); box-shadow: 0 28px 64px rgba(15, 23, 42, 0.08); overflow: hidden; } .editor-topbar { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 18px 22px 12px; } .editor-crumbs { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; } .editor-crumbs strong { color: var(--ink); } .title-input { width: 100%; border: none; outline: none; background: transparent; font-size: 48px; line-height: 1.05; font-weight: 700; color: #0f172a; padding: 8px 22px 6px; } .title-input::placeholder { color: #94a3b8; } .editor-subcopy { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; padding: 0 22px 18px; color: var(--muted); font-size: 14px; } .hint-pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px; background: rgba(15, 118, 110, 0.09); color: var(--accent-strong); font-size: 13px; } .editor-stage { position: relative; width: min(100%, 900px); margin: 0 auto 18px; border: 1px solid rgba(100, 116, 139, 0.18); border-radius: 26px; background: rgba(255, 255, 255, 0.92); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); } .editor-stage[data-page-wide-layout="true"] { width: min(100%, 1120px); } .mnote-mindmap-placeholder { width: min(1280px, calc(100vw - 340px)); margin: 10px 0; margin-left: 50%; transform: translateX(-50%); } .mnote-mindmap-editor-root { position: relative; overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.32); border-radius: 8px; background: #ffffff; color: #0f172a; } .mnote-leptos-mindmap-shell { position: relative; height: min(72vh, 720px); min-height: 560px; display: flex; flex-direction: column; } .mnote-mindmap-command-toolbar, .mnote-mindmap-bottom-bar { display: flex; align-items: center; gap: 6px; padding: 8px; border-bottom: 1px solid rgba(148, 163, 184, 0.26); background: #ffffff; } .mnote-mindmap-command-toolbar { flex-wrap: nowrap; } .mnote-mindmap-schema-toolbar { position: absolute; left: 50%; top: 22px; z-index: 4; width: max-content; max-width: min(980px, calc(100% - 184px)); box-sizing: border-box; transform: translateX(-50%); gap: 8px; padding: 7px 12px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 6px; background: rgba(255, 255, 255, 0.95); box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1); backdrop-filter: blur(10px); overflow-x: visible; overflow-y: visible; scrollbar-width: none; } .mnote-mindmap-toolbar-primary, .mnote-mindmap-toolbar-secondary { display: flex; align-items: center; gap: 8px; justify-content: center; flex-wrap: nowrap; min-width: 0; } .mnote-mindmap-toolbar-cluster { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; min-width: 0; white-space: nowrap; } .mnote-mindmap-toolbar-cluster-file { padding-left: 8px; border-left: 1px solid rgba(148, 163, 184, 0.24); } .mnote-mindmap-toolbar-group { display: inline-flex; align-items: center; gap: 4px; padding-right: 10px; border-right: 1px solid rgba(148, 163, 184, 0.22); } .mnote-mindmap-toolbar-group:last-child { border-right: 0; } .mnote-mindmap-toolbar-group-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); color: #64748b; font-size: 11px; } .mnote-mindmap-toolbar-primary input, .mnote-mindmap-bottom-bar input { height: 28px; border: 1px solid rgba(148, 163, 184, 0.42); border-radius: 6px; padding: 0 8px; background: #fff; color: #0f172a; font-size: 12px; } .mnote-mindmap-tool, .mnote-mindmap-bottom-bar button, .mnote-mindmap-side-tab { height: 28px; border: 1px solid rgba(148, 163, 184, 0.34); border-radius: 6px; padding: 0 9px; background: #fff; color: #334155; font-size: 12px; cursor: pointer; } .mnote-mindmap-schema-toolbar .mnote-mindmap-tool { flex-direction: column; display: inline-flex; align-items: center; justify-content: center; gap: 2px; width: 46px; height: 40px; min-width: 46px; padding: 3px 4px; color: #475569; } .mnote-mindmap-tool-icon { min-width: 16px; font-size: 15px; line-height: 1; } .mnote-mindmap-tool-label { max-width: 42px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; line-height: 14px; } .mnote-mindmap-tool:disabled, .mnote-mindmap-bottom-bar button:disabled, .mnote-mindmap-side-tab:disabled { cursor: default; opacity: 0.48; } .mnote-mindmap-toolbar-more { position: relative; display: inline-flex; flex: 0 0 auto; } .mnote-mindmap-toolbar-more-button[data-open="true"] { border-color: rgba(37, 99, 235, 0.42); background: #eff6ff; color: #1d4ed8; } .mnote-mindmap-toolbar-more-menu { position: absolute; right: 0; top: calc(100% + 8px); z-index: 8; display: grid; grid-template-columns: 1fr; gap: 4px; min-width: 128px; padding: 8px; border: 1px solid rgba(15, 23, 42, 0.1); border-radius: 6px; background: #ffffff; box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16); } .mnote-mindmap-toolbar-more-menu .mnote-mindmap-tool { flex-direction: row; justify-content: flex-start; width: 100%; min-width: 112px; height: 30px; gap: 8px; padding: 0 8px; } .mnote-mindmap-toolbar-more-menu .mnote-mindmap-tool-label { max-width: none; font-size: 12px; } .mnote-mindmap-workspace { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 460px; } .mnote-mindmap-workspace[data-layout="floating-overlay"] { position: relative; display: block; width: 100%; height: 100%; min-height: 560px; overflow: hidden; background: #f7f8fa; } .mnote-mindmap-canvas-layer { position: absolute; inset: 0; z-index: 1; } .mnote-mindmap-overlay-layer { position: absolute; inset: 0; z-index: 3; pointer-events: none; } .mnote-mindmap-overlay-layer > * { pointer-events: auto; } .mnote-mindmap-rust-shell-mount, .mnote-mindmap-rust-shell { position: absolute; inset: 0; z-index: 4; pointer-events: none; } .mnote-mindmap-rust-shell > * { pointer-events: auto; } .mnote-mindmap-workspace[data-debug-chrome="true"] { grid-template-columns: minmax(0, 1fr) 132px; } .mnote-mindmap-workspace[data-debug-chrome="false"] { grid-template-columns: minmax(0, 1fr) 172px; } .mnote-leptos-mindmap-runtime { position: relative; height: 460px; min-height: 460px; overflow: hidden; background: #ffffff; } .mnote-mindmap-workspace[data-layout="floating-overlay"] .mnote-leptos-mindmap-runtime { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 100%; background: #f7f8fa; } .mnote-leptos-mindmap-runtime svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: auto; } .mnote-leptos-mindmap-runtime .smm-node { cursor: default; } .mnote-mindmap-side-panel { border-left: 1px solid rgba(148, 163, 184, 0.26); background: #fff; padding: 8px; } .mnote-mindmap-schema-sidebar { position: absolute; right: 18px; top: 50%; z-index: 4; width: auto; max-height: calc(100% - 130px); transform: translateY(-50%); display: flex; align-items: flex-start; gap: 12px; border: 0; border-radius: 0; background: transparent; box-shadow: none; backdrop-filter: none; padding: 0; } .mnote-mindmap-side-rail { width: 60px; display: flex; flex-direction: column; gap: 8px; padding: 10px 8px 8px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 10px; background: rgba(255, 255, 255, 0.96); box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1); backdrop-filter: blur(10px); } .mnote-mindmap-side-rail-handle, .mnote-mindmap-side-restore-handle, .mnote-mindmap-side-drawer-close { display: inline-flex; align-items: center; justify-content: center; border: 1px solid rgba(148, 163, 184, 0.24); background: rgba(248, 250, 252, 0.98); color: #475569; } .mnote-mindmap-side-rail-handle { width: 100%; min-height: 24px; border-radius: 8px; } .mnote-mindmap-side-restore-handle { width: 22px; min-height: 72px; margin-top: 64px; border-radius: 999px; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1); } .mnote-mindmap-side-rail-handle:hover, .mnote-mindmap-side-restore-handle:hover, .mnote-mindmap-side-drawer-close:hover { border-color: rgba(37, 99, 235, 0.36); background: #eff6ff; color: #1d4ed8; } .mnote-mindmap-side-tab { width: 100%; display: inline-flex; flex-direction: column; align-items: center; gap: 3px; justify-content: center; margin-bottom: 0; min-height: 56px; padding: 8px 2px; text-align: center; border: 0; border-radius: 8px; background: transparent; color: #475569; position: relative; } .mnote-mindmap-side-icon { width: 100%; overflow: hidden; color: #64748b; font-size: 13px; text-overflow: ellipsis; } .mnote-mindmap-side-tab-label { font-size: 11px; line-height: 1.2; } .mnote-mindmap-side-tab[data-active="true"] { background: #eff6ff; color: #1d4ed8; } .mnote-mindmap-side-tab[data-active="true"]::before { content: ""; position: absolute; left: -8px; top: 8px; bottom: 8px; width: 3px; border-radius: 999px; background: #2563eb; } .mnote-mindmap-side-body { position: absolute; right: 72px; top: 0; width: 300px; margin-top: 0; color: #64748b; font-size: 12px; padding: 16px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 12px; background: rgba(255, 255, 255, 0.96); box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1); backdrop-filter: blur(10px); } .mnote-mindmap-schema-sidebar .mnote-mindmap-side-body { padding-top: 16px; } .mnote-mindmap-side-body strong, .mnote-mindmap-side-body span { display: block; } .mnote-mindmap-side-drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .mnote-mindmap-side-drawer-heading { min-width: 0; flex: 1; } .mnote-mindmap-side-drawer-heading strong { color: #0f172a; font-size: 15px; line-height: 1.3; } .mnote-mindmap-side-drawer-heading span { margin-top: 4px; color: #64748b; font-size: 12px; } .mnote-mindmap-side-drawer-close { width: 28px; min-width: 28px; min-height: 28px; border-radius: 8px; } .mnote-mindmap-side-swatches { display: flex; gap: 5px; margin-top: 8px; } .mnote-mindmap-side-swatches i { width: 18px; height: 18px; border: 1px solid rgba(148, 163, 184, 0.38); border-radius: 4px; background: #f8fafc; } .mnote-mindmap-side-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 16px; } .mnote-mindmap-side-option { min-height: 40px; padding: 10px; border: 1px solid rgba(148, 163, 184, 0.28); border-radius: 8px; background: #f8fafc; color: #334155; font-size: 12px; line-height: 1.3; text-align: left; } .mnote-mindmap-side-option:hover { border-color: rgba(37, 99, 235, 0.38); background: #eff6ff; color: #1d4ed8; } .mnote-mindmap-side-option-readonly { background: #fff; } .mnote-mindmap-side-option-label { color: inherit; font-weight: 500; } .mnote-mindmap-side-option-description { margin-top: 4px; color: #64748b; font-size: 11px; } .mnote-mindmap-side-option-swatch { display: flex; align-items: center; gap: 10px; } .mnote-mindmap-side-option-swatch-chip { width: 18px; height: 18px; min-width: 18px; border: 1px solid rgba(148, 163, 184, 0.28); border-radius: 999px; } .mnote-mindmap-side-option-layout-card { display: flex; min-height: 86px; flex-direction: column; justify-content: flex-start; gap: 8px; } .mnote-mindmap-side-option-preview-card { display: inline-flex; align-items: center; justify-content: center; width: 100%; min-height: 38px; border-radius: 6px; background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%); color: #1d4ed8; font-size: 11px; line-height: 1; } .mnote-mindmap-side-outline { max-height: 172px; margin: 16px 0 0; padding-left: 14px; overflow: auto; color: #334155; font-size: 11px; line-height: 1.45; } .mnote-mindmap-side-outline:empty { display: none; } .mnote-mindmap-side-swatches i:nth-child(2) { background: #eef2ff; } .mnote-mindmap-side-swatches i:nth-child(3) { background: #ecfeff; } .mnote-mindmap-bottom-bar { border-top: 1px solid rgba(148, 163, 184, 0.26); border-bottom: 0; font-size: 12px; color: #475569; } .mnote-mindmap-schema-navigator { position: absolute; right: 28px; bottom: 22px; z-index: 4; display: inline-flex; align-items: center; gap: 8px; justify-content: flex-end; min-height: 40px; padding: 6px 8px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 6px; background: rgba(255, 255, 255, 0.94); box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); backdrop-filter: blur(10px); } .mnote-mindmap-schema-navigator [data-testid="mindmap-schema-navigator-stats"] { margin-right: auto; } .mnote-mindmap-schema-navigator-group { display: inline-flex; align-items: center; gap: 6px; } .mnote-mindmap-schema-navigator-button { display: inline-flex; align-items: center; justify-content: center; width: 30px; min-width: 30px; padding: 0; } .mnote-mindmap-schema-navigator-button[data-active="true"] { border-color: rgba(37, 99, 235, 0.32); background: rgba(219, 234, 254, 0.94); color: #1d4ed8; } .mnote-mindmap-schema-navigator-zoom { width: 62px; text-align: center; font-variant-numeric: tabular-nums; } .mnote-mindmap-schema-navigator-search-wrap { display: inline-flex; align-items: center; gap: 6px; } .mnote-mindmap-schema-navigator-search-wrap input { width: 136px; } .mnote-mindmap-minimap { position: absolute; right: 28px; bottom: 76px; z-index: 4; width: 180px; height: 118px; border: 1px solid rgba(15, 23, 42, 0.12); border-radius: 6px; background: rgba(255, 255, 255, 0.9); box-shadow: 0 2px 16px rgba(15, 23, 42, 0.08); pointer-events: auto; } .mnote-mindmap-minimap-map { position: absolute; inset: 12px; } .mnote-mindmap-minimap-map span { position: absolute; display: block; width: 42px; height: 14px; border-radius: 4px; background: #dbeafe; } .mnote-mindmap-minimap-map span:nth-child(1) { left: 54px; top: 8px; background: #bfdbfe; } .mnote-mindmap-minimap-map span:nth-child(2) { left: 22px; top: 52px; background: #dcfce7; } .mnote-mindmap-minimap-map span:nth-child(3) { right: 22px; top: 52px; background: #fde68a; } .mnote-mindmap-minimap-viewport { position: absolute; left: 38px; top: 30px; width: 104px; height: 62px; border: 2px solid rgba(37, 99, 235, 0.75); border-radius: 5px; background: rgba(37, 99, 235, 0.08); } .mnote-mindmap-context-menu { position: absolute; z-index: 8; min-width: 132px; padding: 6px; border: 1px solid rgba(15, 23, 42, 0.12); border-radius: 6px; background: rgba(255, 255, 255, 0.96); box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14); pointer-events: auto; } .mnote-mindmap-context-menu button { display: block; width: 100%; min-height: 26px; padding: 4px 8px; border: 0; border-radius: 4px; background: transparent; color: #334155; font-size: 12px; text-align: left; } .mnote-mindmap-context-menu button:hover { background: #eff6ff; color: #1d4ed8; } .mnote-mindmap-count { position: absolute; left: 28px; bottom: 30px; z-index: 4; display: flex; gap: 18px; color: #64748b; font-size: 12px; pointer-events: auto; } .block-handle-shell { position: absolute; left: 12px; z-index: 80; display: inline-flex; flex-direction: column; align-items: center; gap: 1px; width: 26px; padding: 0; background: transparent; pointer-events: none; } .block-handle-shell[data-dragging="true"] { pointer-events: none; opacity: 1; } .block-handle-insert, .block-handle-trigger { position: relative; width: 22px; height: 20px; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 5px; background: transparent; color: #6b7280; cursor: pointer; pointer-events: auto; transition: background .12s ease, border-color .12s ease, box-shadow .12s ease, color .12s ease, opacity .12s ease; } .block-handle-insert { opacity: 0; } .block-handle-shell[data-menu-open="true"] .block-handle-insert { opacity: 0; pointer-events: none; } .block-handle-trigger { height: 22px; border: 1px solid rgba(148, 163, 184, 0.42); background: rgba(255, 255, 255, 0.96); box-shadow: 0 1px 4px rgba(15, 23, 42, 0.1); cursor: grab; } .block-handle-trigger:hover, .block-handle-shell[data-keyboard-selected="true"] .block-handle-trigger, .block-handle-shell[data-menu-open="true"] .block-handle-trigger { border-color: rgba(107, 114, 128, 0.5); background: #f8fafc; color: #4b5563; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14); } .block-handle-trigger:active { cursor: grabbing; } .block-handle-icon-dots { display: grid; grid-template-columns: repeat(2, 3px); grid-auto-rows: 3px; gap: 3px; } .block-handle-icon-dots span, .block-handle-icon-lines span { width: 3px; height: 3px; border-radius: 999px; background: currentColor; } .block-handle-icon-lines { display: grid; gap: 3px; } .block-handle-icon-lines span { display: none; width: 12px; height: 1.5px; border-radius: 999px; } .block-handle-shell[data-menu-open="true"] .block-handle-icon-dots span { display: none; } .block-handle-shell[data-menu-open="true"] .block-handle-icon-lines span { display: block; } .block-insert-line { display: none; } .block-insert-plus { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border: 1px solid rgba(148, 163, 184, 0.42); border-radius: 5px; background: rgba(255, 255, 255, 0.96); color: #5f6773; font-size: 16px; line-height: 1; box-shadow: 0 1px 4px rgba(15, 23, 42, 0.1); } .block-handle-insert:hover, .block-handle-insert:focus-visible { opacity: 1; } .block-handle-insert:hover .block-insert-plus, .block-handle-insert:focus-visible .block-insert-plus { border-color: rgba(107, 114, 128, 0.5); background: #f8fafc; color: #4b5563; } .block-insert-tooltip { position: absolute; left: 26px; top: 50%; transform: translateY(-50%); min-width: 118px; display: none; gap: 5px; align-items: center; padding: 5px 7px; border-radius: 5px; background: rgba(17, 24, 39, 0.94); color: #fff; font-size: 12px; line-height: 1.2; white-space: nowrap; box-shadow: 0 8px 22px rgba(15, 23, 42, 0.2); } .block-insert-tooltip kbd { color: rgba(255, 255, 255, 0.72); font: inherit; } .block-handle-insert:hover .block-insert-tooltip, .block-handle-insert:focus-visible .block-insert-tooltip { display: inline-flex; } .block-drag-menu { position: fixed; top: 0; left: 0; width: 242px; min-width: 242px; box-sizing: border-box; display: grid; gap: 0; padding: 10px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 8px; background: #fff; background-image: linear-gradient(#fff, #fff); color: var(--ink); box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18); z-index: 120; pointer-events: auto; isolation: isolate; overflow: visible; overscroll-behavior: contain; scrollbar-width: thin; } .block-drag-menu::before { content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: #fff; } .block-drag-menu > * { position: relative; z-index: 1; } .block-drag-menu-label, .block-drag-menu-actions, .block-drag-menu-section, .block-drag-menu-item { background-color: #fff; } .block-drag-command-input { width: 100%; height: 34px; padding: 0 10px; border: 1px solid rgba(148, 163, 184, 0.24); border-radius: 5px; background: #fff; color: #9ca3af; font: inherit; } .block-drag-menu-label { display: grid; gap: 4px; } .block-drag-menu-label strong { font-size: 14px; } .block-drag-menu-label span { color: var(--muted); font-size: 12px; } .block-drag-menu-shortcut, .block-drag-menu-arrow { margin-left: auto; color: #a1a1aa; font-size: 13px; white-space: nowrap; } .block-drag-menu-icon { min-width: 22px; color: #60646c; font-size: 16px; line-height: 1; text-align: center; } .block-drag-menu-switch { margin-left: auto; width: 28px; height: 16px; border-radius: 999px; background: #d4d4d8; position: relative; } .block-drag-menu-switch::after { content: ""; position: absolute; width: 12px; height: 12px; left: 2px; top: 2px; border-radius: 999px; background: #fff; } .block-drag-submenu { position: absolute; left: calc(100% + 8px); top: 0; width: 206px; max-height: min(620px, 80vh); overflow-y: auto; display: grid; gap: 0; padding: 6px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 8px; background: #fff; box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18); z-index: 130; pointer-events: auto; } .block-drag-tertiary-submenu { position: absolute; left: calc(100% + 8px); top: 0; width: 206px; display: grid; gap: 0; padding: 6px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 8px; background: #fff; box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18); z-index: 140; pointer-events: auto; } .block-drag-menu-item[disabled] { color: #a1a1aa; cursor: default; } .block-drag-menu-item[disabled]:hover { background: transparent; } .block-drag-menu-back { width: 100%; display: inline-flex; align-items: center; gap: 8px; border: none; background: transparent; color: var(--muted); padding: 2px 2px 6px; cursor: pointer; } .block-drag-menu-back:hover { color: var(--accent-strong); } .block-drag-menu-actions { display: grid; gap: 0; } .block-drag-menu-section { display: grid; gap: 0; } .block-drag-menu-divider { height: 1px; margin: 4px -10px; background: rgba(148, 163, 184, 0.22); } .block-drag-menu-item { width: 100%; display: flex; align-items: center; justify-content: flex-start; gap: 10px; text-align: left; border: 1px solid transparent; border-radius: 6px; background: transparent; color: inherit; min-height: 31px; padding: 5px 8px; white-space: nowrap; cursor: pointer; transition: background .12s ease, border-color .12s ease; } .block-drag-menu-item:hover { background: rgba(17, 24, 39, 0.06); border-color: transparent; } .block-drag-menu-item > span:not(.block-drag-menu-icon):not(.block-drag-menu-shortcut):not(.block-drag-menu-arrow):not(.block-drag-menu-switch) { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .block-drag-menu-footer { display: grid; gap: 2px; margin: 6px -2px -2px; padding-top: 8px; border-top: 1px solid rgba(148, 163, 184, 0.18); color: #a1a1aa; font-size: 12px; line-height: 1.35; } .block-drop-indicator { position: absolute; left: 56px; right: 34px; height: 2px; border-radius: 999px; background: rgba(15, 118, 110, 0.78); box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12); z-index: 13; } .block-layout-outline { position: absolute; left: 56px; right: 34px; min-height: 30px; border: 1px dashed rgba(148, 163, 184, 0.95); border-radius: 4px; background: rgba(255, 71, 71, 0.06); pointer-events: none; z-index: 12; } .floating-toolbar { position: absolute; left: 0; top: 0; transform: translate(-50%, calc(-100% - 14px)); z-index: 12; display: flex; align-items: center; gap: 2px; flex-wrap: nowrap; max-width: min(720px, calc(100% - 32px)); padding: 3px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 8px; background: rgba(255, 255, 255, 0.98); color: #0f172a; box-shadow: 0 14px 34px rgba(15, 23, 42, 0.14); backdrop-filter: blur(16px); } .floating-toolbar-group { display: flex; align-items: center; gap: 2px; } .image-floating-toolbar { position: absolute; left: 0; top: 0; transform: translate(-50%, calc(-100% - 12px)); z-index: 122; display: inline-flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid rgba(15, 23, 42, 0.1); border-radius: 8px; background: rgba(255, 255, 255, 0.98); color: #111827; box-shadow: 0 14px 34px rgba(15, 23, 42, 0.16); backdrop-filter: blur(16px); pointer-events: auto; } .image-toolbar-btn { min-width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; padding: 0 8px; border: none; border-radius: 6px; background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } .image-toolbar-btn:hover, .image-toolbar-btn:focus-visible, .image-toolbar-btn[data-active="true"] { background: rgba(255, 229, 229, 0.95); outline: none; } .image-toolbar-btn:disabled { color: #a1a1aa; cursor: not-allowed; opacity: 0.55; background: transparent; } .image-toolbar-separator { width: 1px; align-self: stretch; min-height: 22px; background: rgba(148, 163, 184, 0.3); } .toolbar-popover-anchor { position: relative; display: inline-flex; align-items: center; } .toolbar-separator { width: 1px; align-self: stretch; margin: 4px 3px; background: rgba(148, 163, 184, 0.28); } .toolbar-btn { min-width: 30px; height: 30px; border: none; border-radius: 6px; background: transparent; color: inherit; padding: 0 10px; cursor: pointer; transition: background .12s ease, border-color .12s ease, transform .12s ease; font-size: 14px; font-weight: 600; } .toolbar-btn:hover { background: rgba(148, 163, 184, 0.16); } .toolbar-btn[data-active="true"] { background: rgba(15, 118, 110, 0.14); color: var(--accent-strong); } .toolbar-btn[data-wide="true"] { min-width: 56px; justify-content: space-between; } .turn-into-panel { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%); min-width: 220px; max-height: min(420px, calc(100vh - 32px)); display: grid; gap: 6px; padding: 10px; border-radius: 18px; border: 1px solid rgba(15, 23, 42, 0.1); background: rgba(255, 255, 255, 0.98); color: var(--ink); box-shadow: 0 20px 44px rgba(15, 23, 42, 0.16); overflow-y: auto; overscroll-behavior: contain; z-index: 2; } .toolbar-popover-panel { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%); min-width: 248px; max-height: min(440px, calc(100vh - 32px)); display: grid; gap: 10px; padding: 12px; border-radius: 18px; border: 1px solid rgba(15, 23, 42, 0.1); background: rgba(255, 255, 255, 0.98); color: var(--ink); box-shadow: 0 20px 44px rgba(15, 23, 42, 0.16); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; z-index: 2; } .turn-into-item, .slash-item, .toolbar-menu-item { width: 100%; display: grid; gap: 4px; text-align: left; border: 1px solid transparent; border-radius: 14px; background: transparent; color: inherit; padding: 10px 12px; cursor: pointer; transition: background .12s ease, border-color .12s ease; } .turn-into-item:hover, .slash-item:hover, .slash-item[data-active="true"], .toolbar-menu-item:hover, .toolbar-menu-item[data-active="true"] { background: rgba(15, 118, 110, 0.08); border-color: rgba(15, 118, 110, 0.18); } .turn-into-item > span, .slash-item-copy > span, .toolbar-menu-item > span { color: var(--muted); font-size: 12px; } .slash-item-copy strong { color: #30343b; font-size: 14px; font-weight: 500; } .toolbar-popover-section { display: grid; gap: 8px; } .toolbar-popover-title { color: var(--muted); font-size: 12px; font-weight: 700; letter-spacing: 0.02em; } .toolbar-color-grid { display: grid; gap: 6px; grid-template-columns: repeat(2, minmax(0, 1fr)); } .toolbar-color-item { display: flex; align-items: center; gap: 10px; } .toolbar-color-chip { width: 18px; height: 18px; flex: 0 0 18px; border-radius: 999px; border: 1px solid rgba(15, 23, 42, 0.14); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6); } .toolbar-more-list { display: grid; gap: 6px; } .toolbar-more-item strong { font-size: 14px; } .slash-menu { position: fixed; top: 24px; left: 24px; z-index: 130; width: min(316px, calc(100vw - 16px)); max-height: min(430px, calc(100vh - 16px)); display: grid; gap: 0; padding: 8px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 8px; background: #fff; box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; } .slash-list { display: grid; gap: 2px; } .slash-section-label { padding: 10px 6px 5px; color: #9097a3; font-size: 12px; } .slash-item-row { display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; align-items: center; gap: 8px; } .slash-item-icon { color: #6b7280; font-size: 14px; } .slash-item-copy { display: grid; gap: 1px; min-width: 0; } .slash-item-shortcut { color: #a0a7b2; font-size: 12px; } .editor-surface { display: block; width: 100%; min-height: 580px; padding: 76px 0 32px; } .app-shell-embedded .editor-surface { min-height: 640px; padding: 18px 0 28vh; } .editor-surface .ProseMirror { outline: none; min-height: 472px; line-height: 1.78; font-size: 17px; color: #1f2937; } .editor-surface .ProseMirror img[src] { display: block; max-width: 100%; height: auto; margin: 8px 0; border-radius: 6px; } .editor-surface .ProseMirror img[data-align="center"] { margin-left: auto; margin-right: auto; } .editor-surface .ProseMirror img[data-align="right"] { margin-left: auto; margin-right: 0; } .editor-surface .ProseMirror img.ProseMirror-selectednode { outline: 2px solid rgba(255, 71, 71, 0.78); outline-offset: 3px; box-shadow: 0 0 0 5px rgba(255, 71, 71, 0.12); } .editor-surface .ProseMirror .tiptap-table-of-contents-node { display: block; margin: 12px 0; padding: 10px 12px; border: 1px solid rgba(229, 231, 235, 0.95); border-radius: 6px; background: #fff; color: #374151; } .editor-surface .ProseMirror .tiptap-table-of-contents-node.ProseMirror-selectednode { outline: 2px solid rgba(255, 71, 71, 0.62); outline-offset: 2px; } .editor-surface .ProseMirror [data-block-id]:target { outline: 2px solid #2563eb; outline-offset: 3px; background: rgba(37, 99, 235, 0.08); scroll-margin-top: 72px; transition: background 0.18s ease, outline-color 0.18s ease; } .tiptap-table-of-contents-title-row { display: flex; align-items: center; justify-content: space-between; min-height: 24px; gap: 8px; } .tiptap-table-of-contents-title { font-size: 14px; line-height: 20px; font-weight: 600; color: #4b5563; } .tiptap-table-of-contents-title-toggle { width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 4px; background: transparent; color: #9ca3af; font-size: 12px; cursor: pointer; } .tiptap-table-of-contents-title-toggle:hover { background: rgba(17, 24, 39, 0.06); color: #4b5563; } .tiptap-table-of-contents-list { display: grid; gap: 2px; margin-top: 6px; } .tiptap-table-of-contents-item { display: block; min-height: 24px; padding: 2px 6px; border-radius: 4px; color: #6b7280; font-size: 14px; line-height: 20px; text-decoration: none; } .tiptap-table-of-contents-item[data-depth="2"] { padding-left: 20px; } .tiptap-table-of-contents-item[data-depth="3"] { padding-left: 34px; } .tiptap-table-of-contents-item[data-depth="4"] { padding-left: 48px; } .tiptap-table-of-contents-item[data-depth="5"] { padding-left: 62px; } .tiptap-table-of-contents-item[data-depth="6"] { padding-left: 76px; } .tiptap-table-of-contents-item:hover { background: rgba(17, 24, 39, 0.05); color: #374151; } .tiptap-table-of-contents-empty { margin-top: 4px; color: #9ca3af; font-size: 14px; line-height: 22px; } .editor-surface .ProseMirror .tableWrapper { width: 100%; overflow-x: auto; margin: 8px 0; } .editor-surface .ProseMirror table { width: 100%; border-collapse: collapse; table-layout: fixed; margin: 8px 0; } .editor-surface .ProseMirror .tableWrapper table { margin: 0; } .editor-surface .ProseMirror td, .editor-surface .ProseMirror th { position: relative; min-width: 84px; height: 32px; padding: 5px 8px; border: 1px solid rgba(148, 163, 184, 0.72); vertical-align: top; } .editor-surface .ProseMirror th { background: rgba(248, 250, 252, 0.96); font-weight: 600; } .editor-surface .ProseMirror table[data-hidden-borders="true"] td, .editor-surface .ProseMirror table[data-hidden-borders="true"] th { border-width: 0; } .editor-surface .ProseMirror .selectedCell::after { content: ""; position: absolute; inset: 0; background: rgba(59, 130, 246, 0.12); pointer-events: none; } .editor-surface .ProseMirror .column-resize-handle { position: absolute; top: 0; right: -4px; bottom: -1px; width: 8px; background: rgba(239, 68, 68, 0.34); cursor: col-resize; pointer-events: none; z-index: 4; } .editor-surface .ProseMirror .column-resize-dragging { background: rgba(255, 71, 71, 0.08); } .editor-surface .ProseMirror.resize-cursor, .editor-surface .ProseMirror.resize-cursor * { cursor: col-resize !important; } .table-controls { position: absolute; z-index: 118; pointer-events: none; } .table-aux-btn { position: absolute; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: none; border-radius: 4px; background: transparent; color: rgba(148, 163, 184, 0.95); font: inherit; font-size: 15px; line-height: 1; cursor: pointer; pointer-events: auto; } .table-aux-btn:hover, .table-aux-btn:focus-visible, .table-aux-btn[data-active="true"] { background: rgba(255, 229, 229, 0.95); color: rgba(107, 114, 128, 0.96); outline: none; } .table-selection-overlay { position: absolute; border-radius: 4px; background: rgba(255, 71, 71, 0.12); outline: 1px solid rgba(255, 71, 71, 0.18); pointer-events: none; } .table-toolbar { position: absolute; top: 34px; right: 44px; display: inline-flex; align-items: center; gap: 4px; padding: 5px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 8px; background: #fff; color: #111827; box-shadow: 0 14px 34px rgba(15, 23, 42, 0.16); z-index: 120; pointer-events: auto; } .table-toolbar-btn { min-width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; padding: 0 8px; border: none; border-radius: 5px; background: transparent; color: #111827; font: inherit; font-size: 13px; cursor: pointer; } .table-toolbar-btn:hover, .table-toolbar-btn:focus-visible { background: rgba(241, 245, 249, 0.95); outline: none; } .table-toolbar-separator { align-self: stretch; width: 1px; min-height: 22px; background: rgba(148, 163, 184, 0.3); } .table-options-menu { position: absolute; top: calc(100% + 6px); right: 0; width: 160px; display: grid; gap: 2px; padding: 6px; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 8px; background: #fff; color: #111827; box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16); z-index: 125; } .table-option-btn { width: 100%; min-height: 30px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 5px 8px; border: none; border-radius: 5px; background: transparent; color: #111827; font: inherit; font-size: 13px; cursor: pointer; text-align: left; } .table-option-btn:hover, .table-option-btn:focus-visible { background: rgba(241, 245, 249, 0.95); outline: none; } .table-option-switch { position: relative; width: 28px; height: 16px; flex: 0 0 auto; border-radius: 999px; background: #d1d5db; transition: background .12s ease; } .table-option-switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 999px; background: #fff; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.2); transition: transform .12s ease; } .table-option-btn[aria-checked="true"] .table-option-switch { background: #9ca3af; } .table-option-btn[aria-checked="true"] .table-option-switch::after { transform: translateX(12px); } .app-shell-embedded .editor-surface .ProseMirror { min-height: 600px; width: min(100%, 708px); margin: 0 auto; padding: 2.25rem 3rem 30vh; font-family: "DM Sans", "Noto Sans SC", "PingFang SC", sans-serif; } .app-shell-embedded .editor-surface[data-small-text="true"] .ProseMirror, .editor-surface[data-small-text="true"] .ProseMirror { font-size: 15px; line-height: 1.66; } .app-shell-embedded .editor-surface[data-layout-density="compact"] .ProseMirror, .editor-surface[data-layout-density="compact"] .ProseMirror { line-height: 1.54; } .app-shell-embedded .editor-surface[data-layout-density="spacious"] .ProseMirror, .editor-surface[data-layout-density="spacious"] .ProseMirror { line-height: 1.92; } .editor-surface .ProseMirror h1, .editor-surface .ProseMirror h2, .editor-surface .ProseMirror h3, .editor-surface .ProseMirror h4 { line-height: 1.12; color: #0f172a; margin: 1.12em 0 0.46em; letter-spacing: -0.02em; } .editor-surface .ProseMirror h1 { font-size: 2.1em; } .editor-surface .ProseMirror h2 { font-size: 1.56em; } .editor-surface .ProseMirror h3 { font-size: 1.28em; } .editor-surface .ProseMirror h4 { font-size: 1.08em; } .editor-surface .ProseMirror ul, .editor-surface .ProseMirror ol { padding-left: 1.5em; } .editor-surface .ProseMirror ul[data-type="taskList"] { list-style: none; padding-left: 0.25em; } .editor-surface .ProseMirror ul[data-type="taskList"] li { display: flex; flex-direction: row; align-items: flex-start; } .editor-surface .ProseMirror ul[data-type="taskList"] li:not(:has(> p:first-child)) { list-style-type: none; } .editor-surface .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p, .editor-surface .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p span { opacity: 0.5; text-decoration: line-through; } .editor-surface .ProseMirror ul[data-type="taskList"] li label { position: relative; padding-top: 0.375rem; padding-right: 0.5rem; } .editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; } .editor-surface .ProseMirror ul[data-type="taskList"] li label span { display: block; width: 1em; height: 1em; border: 1px solid rgba(148, 163, 184, 0.7); border-radius: 0.25rem; position: relative; cursor: pointer; background-color: rgba(255, 255, 255, 0.96); transition: background-color 80ms ease-out, border-color 80ms ease-out; } .editor-surface .ProseMirror ul[data-type="taskList"] li label span::before { content: ""; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 0.75em; height: 0.75em; background-color: #ffffff; opacity: 0; -webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") center/contain no-repeat; mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") center/contain no-repeat; } .editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"]:checked + span { background: var(--accent-strong); border-color: var(--accent-strong); } .editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"]:checked + span::before { opacity: 1; } .editor-surface .ProseMirror ul[data-type="taskList"] li > div { flex: 1 1 0%; min-width: 0; } .editor-surface .ProseMirror ul[data-type="taskList"] li > div > p { margin: 0; } .editor-surface .ProseMirror pre { background: #14222b; color: #f8fafc; padding: 16px 18px; border-radius: 18px; overflow: auto; } .editor-surface .ProseMirror blockquote { margin: 1.1em 0; padding-left: 16px; border-left: 3px solid rgba(15, 118, 110, 0.5); color: #334155; } .editor-surface .ProseMirror hr { margin: 1.6em 0; border: none; border-top: 1px solid rgba(100, 116, 139, 0.28); } .editor-surface .ProseMirror p.is-editor-empty:first-child::before, .editor-surface .ProseMirror p.is-empty::before { color: #94a3b8; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; } .editor-surface .ProseMirror p.is-editor-empty, .editor-surface .ProseMirror p.is-empty { min-height: 1.6em; } .editor-surface .ProseMirror p:has(> a.mnote-page-block-link) { width: 100%; margin: 0 0 8px; padding: 2px 6px; border-radius: 4px; background: rgba(255, 71, 71, 0.12); } .editor-surface .ProseMirror a.mnote-page-block-link { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 1px 4px; border-radius: 4px; color: #37352f; font-weight: 600; line-height: 1.45; text-decoration: none; cursor: pointer; } .editor-surface .ProseMirror a.mnote-page-block-link::before { content: ""; width: 14px; height: 14px; flex: 0 0 auto; border: 1.4px solid #c8c5c0; border-radius: 3px; background: linear-gradient(180deg, #ffffff 0%, #f6f4f1 100%); box-shadow: inset 0 -2px 0 rgba(55, 53, 47, 0.08); } .editor-surface .ProseMirror a.mnote-page-block-link:hover { color: #37352f; background: rgba(55, 53, 47, 0.08); text-decoration: none; } .editor-surface .ProseMirror a.mnote-uploaded-attachment-row { display: inline-flex; align-items: center; gap: 7px; max-width: 100%; min-height: 28px; margin: 1px 0; padding: 2px 4px; border: 0; border-radius: 4px; color: #37352f; font-weight: 500; line-height: 1.4; text-decoration: none; background: transparent; box-shadow: none; } .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before { content: ""; width: 18px; height: 18px; flex: 0 0 auto; border-radius: 4px; background: var(--attachment-icon-bg, #e9ecef); border: 1px solid var(--attachment-icon-border, rgba(55, 53, 47, 0.14)); } .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::after { content: "◉"; flex: 0 0 auto; margin-left: 4px; color: #9ca3af; font-size: 11px; } .editor-surface .ProseMirror a.mnote-uploaded-attachment-row:hover { background: rgba(55, 53, 47, 0.06); text-decoration: none; } .editor-surface .ProseMirror a.mnote-uploaded-attachment-word { --attachment-icon-bg: #4f82ff; --attachment-icon-border: #3366d6; } .editor-surface .ProseMirror a.mnote-uploaded-attachment-ppt { --attachment-icon-bg: #ea581f; --attachment-icon-border: #cf4817; } .editor-surface .ProseMirror a.mnote-uploaded-attachment-sheet { --attachment-icon-bg: #2f9e44; --attachment-icon-border: #25813a; } .editor-surface .ProseMirror a.mnote-uploaded-attachment-pdf { --attachment-icon-bg: #ef4444; --attachment-icon-border: #dc2626; } .editor-surface .ProseMirror a.mnote-uploaded-attachment-file { --attachment-icon-bg: #9ca3af; --attachment-icon-border: #6b7280; } .footer-strip { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 0 22px 22px; color: var(--muted); font-size: 13px; } .footer-strip code { color: var(--accent-strong); } .debug-drawer { margin-top: 22px; border: 1px solid rgba(100, 116, 139, 0.18); border-radius: 22px; background: rgba(255, 255, 255, 0.85); overflow: hidden; } .debug-drawer summary { cursor: pointer; list-style: none; padding: 18px 20px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; } .debug-drawer summary::-webkit-details-marker { display: none; } .debug-grid { display: grid; gap: 16px; padding: 0 20px 20px; grid-template-columns: repeat(3, minmax(0, 1fr)); } .debug-card { border: 1px solid rgba(100, 116, 139, 0.18); border-radius: 18px; background: rgba(255, 255, 255, 0.88); padding: 16px; } .debug-card h3 { margin: 0 0 10px; font-size: 14px; } .debug-card pre { margin: 0; max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: #1e293b; } @media (max-width: 920px) { .hero-bar, .editor-topbar, .footer-strip { flex-direction: column; align-items: flex-start; } .title-input { font-size: 34px; } .editor-surface { padding: 88px 0 24px; min-height: 460px; } .app-shell-embedded .editor-surface { padding: 12px 0 24vh; } .app-shell-embedded .editor-surface .ProseMirror { padding: 1.5rem 1.5rem 24vh; } .debug-grid { grid-template-columns: 1fr; } .floating-toolbar, .slash-menu, .block-drag-menu { left: 16px; right: 16px; width: auto; min-width: 0; transform: none; } .floating-toolbar { top: 12px; } } @media (max-width: 768px) { .editor-stage { width: 100%; } .mnote-mindmap-workspace { grid-template-columns: minmax(0, 1fr); } .mnote-mindmap-side-panel { border-left: 0; border-top: 1px solid rgba(148, 163, 184, 0.26); } .block-handle-shell, .block-drop-indicator { display: none; } .app-shell-embedded .editor-surface { padding-left: 0; } } "#; #[derive(Clone, Copy, PartialEq, Eq)] enum SlashActionKind { AiAssistant, AiWrite, ContinueWriting, Summarize, MoreAi, Paragraph, Heading1, Heading2, Heading3, Heading4, BulletList, OrderedList, Todo, AdvancedTodo, Quote, CodeBlock, Divider, SimpleTable, Mindmap, Image, UploadAttachment, Toc, } #[derive(Clone, Copy)] struct SlashAction { kind: SlashActionKind, id: &'static str, category: &'static str, icon: &'static str, label: &'static str, description: &'static str, shortcut: &'static str, } #[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: "折叠小标题", }, ]; const SLASH_ACTIONS: [SlashAction; 22] = [ SlashAction { kind: SlashActionKind::AiAssistant, id: "ai-assistant", category: "AI 助理", icon: "✦", label: "AI 助理", description: "按 Wolai 基线保留 AI 入口", shortcut: "/ai", }, SlashAction { kind: SlashActionKind::AiWrite, id: "ai-write", category: "AI 助理", icon: "✎", label: "用 AI 写作", description: "唤起写作辅助入口", shortcut: "/yaixz", }, SlashAction { kind: SlashActionKind::ContinueWriting, id: "continue-writing", category: "AI 助理", icon: "↪", label: "续写", description: "按当前上下文继续写作", shortcut: "/xx", }, SlashAction { kind: SlashActionKind::Summarize, id: "summarize", category: "AI 助理", icon: "≡", label: "总结", description: "对当前内容生成摘要", shortcut: "/zj", }, SlashAction { kind: SlashActionKind::MoreAi, id: "more-ai", category: "AI 助理", icon: "…", label: "更多", description: "更多 AI 命令", shortcut: "›", }, SlashAction { kind: SlashActionKind::Paragraph, id: "paragraph", category: "基础块列表", icon: "Aa", label: "文本", description: "普通正文块", shortcut: "/wb", }, SlashAction { kind: SlashActionKind::Todo, id: "todo", category: "基础块列表", icon: "☑", label: "待办列表", description: "创建可勾选任务", shortcut: "/dblb", }, SlashAction { kind: SlashActionKind::AdvancedTodo, id: "advanced-todo", category: "基础块列表", icon: "☑", label: "高级待办列表", description: "保留 Wolai 高级待办入口", shortcut: "/gjdblb", }, SlashAction { kind: SlashActionKind::Heading1, id: "heading-1", category: "基础块列表", icon: "H1", label: "主标题", description: "一级标题", shortcut: "/h1", }, SlashAction { kind: SlashActionKind::Heading2, id: "heading-2", category: "基础块列表", icon: "H2", label: "大标题", description: "二级标题", shortcut: "/h2", }, SlashAction { kind: SlashActionKind::Heading3, id: "heading-3", category: "基础块列表", icon: "H3", label: "中标题", description: "三级标题", shortcut: "/h3", }, SlashAction { kind: SlashActionKind::Heading4, id: "heading-4", category: "基础块列表", icon: "H4", label: "小标题", description: "四级标题", shortcut: "/h4", }, SlashAction { kind: SlashActionKind::BulletList, id: "bullet", category: "基础块列表", icon: "•", label: "列表", description: "记录普通要点", shortcut: "/lb", }, SlashAction { kind: SlashActionKind::OrderedList, id: "ordered", category: "基础块列表", icon: "1.", label: "数字列表", description: "记录步骤顺序", shortcut: "/szlb", }, SlashAction { kind: SlashActionKind::Quote, id: "quote", category: "基础块列表", icon: "❝", label: "引述文字", description: "包住注释与摘录", shortcut: "/ys", }, SlashAction { kind: SlashActionKind::CodeBlock, id: "code-block", category: "基础块列表", icon: "<> ", label: "代码片段", description: "插入带语义的代码块", shortcut: "/dm", }, SlashAction { kind: SlashActionKind::Divider, id: "divider", category: "基础块列表", icon: "—", label: "分割线", description: "插入分隔线", shortcut: "/fgx", }, SlashAction { kind: SlashActionKind::SimpleTable, id: "simple-table", category: "进阶块列表", icon: "▦", label: "简单表格", description: "插入基础表格", shortcut: "/jdbg", }, SlashAction { kind: SlashActionKind::Mindmap, id: "mindmap", category: "进阶块列表", icon: "⌘", label: "思维导图", description: "插入 kernel projection 导图块", shortcut: "/dt", }, SlashAction { kind: SlashActionKind::Image, id: "image", category: "媒体与附件", icon: "▧", label: "图片", description: "上传并插入图片", shortcut: "/tp", }, SlashAction { kind: SlashActionKind::UploadAttachment, id: "upload-attachment", category: "媒体与附件", icon: "↥", label: "上传附件", description: "上传 Office、PDF 或其他文件", shortcut: "/fj", }, SlashAction { kind: SlashActionKind::Toc, id: "toc", category: "进阶块列表", icon: "☰", label: "页面目录", description: "根据当前标题生成目录", shortcut: "/toc", }, ]; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct BridgeEnvelope where T: Serialize, { protocol: &'static str, runtime: &'static str, version: &'static str, source: &'static str, event: &'static str, payload: T, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct BridgeSelectorsPayload { root: &'static str, stage: &'static str, editor: &'static str, toolbar: &'static str, slash_menu: &'static str, handle: &'static str, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct HostStatusPayload { document_id: Option, workspace_id: Option, title: String, dirty_count: u32, selected_block_index: Option, current_block_id: Option, editor_focused: bool, read_only: bool, slash_open: bool, toolbar_open: bool, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ReadyPayload { runtime_name: &'static str, selectors: BridgeSelectorsPayload, supported_commands: Vec<&'static str>, supports_embedded_mode: bool, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct StatePayload { document_id: Option, workspace_id: Option, title: String, dirty_count: u32, selected_block_index: Option, editor_focused: bool, slash_open: bool, toolbar_open: bool, read_only: bool, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ChangeMetaPayload { dirty_count: u32, editor_focused: bool, slash_open: bool, toolbar_open: bool, selected_block_index: Option, revision: Option, conflict_detection_key: Option, read_only: bool, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ChangePayload { document_id: Option, workspace_id: Option, title: String, content: Value, meta: ChangeMetaPayload, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct HeightPayload { height: f64, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct HostCommandEnvelope { protocol: Option, runtime: Option, version: Option, source: Option, event: Option, payload: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RuntimePageOptions { wide_layout: Option, small_text: Option, layout_density: Option, show_heading_numbers: Option, embed_default_block_id: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct HostCommandPayload { command: Option, document_id: Option, workspace_id: Option, title: Option, content: Option, editable: Option, page_options: Option, block_id: Option, block_index: Option, text: Option, reference_document_id: Option, reference_block_id: Option, current_block_id: Option, selection: Option, revision: Option, conflict_detection_key: Option, read_only: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] 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, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum HostCommandKind { Undo, Redo, ReplaceContent, SetPageOptions, InsertInlineReference, InsertEmbedReference, RequestCurrentBlockId, SetEditable, Focus, Bootstrap, } impl HostCommandKind { fn from_event_name(event_name: &str) -> Option { match event_name { "undo" => Some(Self::Undo), "redo" => Some(Self::Redo), "replaceContent" | "replace-document" => Some(Self::ReplaceContent), "setPageOptions" | "set-page-options" => Some(Self::SetPageOptions), "insertInlineReference" => Some(Self::InsertInlineReference), "insertEmbedReference" => Some(Self::InsertEmbedReference), "requestCurrentBlockId" => Some(Self::RequestCurrentBlockId), "setEditable" | "set-editable" => Some(Self::SetEditable), "focus" => Some(Self::Focus), "bootstrap" => Some(Self::Bootstrap), _ => None, } } fn from_payload(payload: &HostCommandPayload) -> Option { payload.command.as_deref().and_then(Self::from_event_name) } } #[derive(Clone, Debug, PartialEq, Eq)] struct CurrentBlockInfo { index: Option, block_id: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn parses_command_aliases_and_canonical_names() { assert_eq!( HostCommandKind::from_event_name("replace-document"), Some(HostCommandKind::ReplaceContent) ); assert_eq!( HostCommandKind::from_event_name("replaceContent"), Some(HostCommandKind::ReplaceContent) ); assert_eq!( HostCommandKind::from_event_name("requestCurrentBlockId"), Some(HostCommandKind::RequestCurrentBlockId) ); } #[test] fn reads_command_from_payload_field() { let payload = HostCommandPayload { command: Some("setEditable".to_string()), document_id: None, workspace_id: None, title: None, content: None, editable: Some(true), 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, }; assert_eq!( HostCommandKind::from_payload(&payload), Some(HostCommandKind::SetEditable) ); } #[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!(STYLE.contains(".slash-menu")); assert!(STYLE.contains("position: fixed;")); assert!(STYLE.contains("z-index: 130;")); assert!(!STYLE.contains("z-index: 11;")); } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RuntimeDeliveryMode { Standalone, Embedded, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct MountOptions { document_id: Option, workspace_id: Option, title: Option, content: Option, html: Option, editable: Option, read_only: Option, revision: Option, conflict_detection_key: Option, page_options: Option, #[serde(default)] standalone_object: Option, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct RuntimeStandaloneObject { kind: Option, document_id: Option, mindmap_id: Option, } struct RuntimeMountContext { id: u32, target: EventTarget, mode: RuntimeDeliveryMode, } #[derive(Clone, Debug)] struct RuntimeMountOptions { options: MountOptions, mode: RuntimeDeliveryMode, } struct MountedRuntimeListener { target: EventTarget, listener: Closure, } struct MountedRuntime { listeners: Vec, mount_handle: Box, } struct MountedMindmapShell { mount_handle: Box, } impl Drop for MountedRuntime { fn drop(&mut self) { for listener in &self.listeners { let _ = listener.target.remove_event_listener_with_callback( COMMAND_EVENT, listener.listener.as_ref().unchecked_ref(), ); } } } thread_local! { static RUNTIME_MOUNT_CONTEXT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; static MOUNTED_HANDLES: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); static PENDING_RUNTIME_LISTENERS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); static MOUNTED_MINDMAP_SHELLS: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); static NEXT_MOUNT_ID: Cell = const { Cell::new(1) }; } fn next_mount_id() -> u32 { NEXT_MOUNT_ID.with(|cell| { let next = cell.get(); cell.set(next.saturating_add(1).max(1)); next }) } fn set_runtime_mount_context(context: Option) { RUNTIME_MOUNT_CONTEXT.with(|cell| { *cell.borrow_mut() = context; }); } fn runtime_mount_context() -> Option<(u32, EventTarget, RuntimeDeliveryMode)> { RUNTIME_MOUNT_CONTEXT.with(|cell| { cell.borrow() .as_ref() .map(|context| (context.id, context.target.clone(), context.mode)) }) } fn runtime_delivery_mode() -> RuntimeDeliveryMode { resolve_runtime_delivery_mode( runtime_mount_context().map(|(_, _, mode)| mode), url_requests_embedded_mode(), ) } fn set_runtime_mount_options(options: Option) { RUNTIME_MOUNT_OPTIONS.with(|cell| { *cell.borrow_mut() = options; }); } fn runtime_editor_instance_id(mount_id: Option) -> 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 { if let Some((_, target, _)) = runtime_mount_context() { return Some(target); } window() .and_then(|win| win.document()) .and_then(|document| document.body()) .map(|body| body.into()) } fn editor_block_by_id(block_id: &str) -> Option { find_mnote_block_anchor(block_id) } 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(); } fn dispatch_runtime_event(event_name: &'static str, payload: &T) where T: Serialize, { let envelope = BridgeEnvelope { protocol: PROTOCOL, runtime: RUNTIME_NAME, version: RUNTIME_VERSION, source: EVENT_PREFIX, event: event_name, payload, }; let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else { return; }; let Some(target) = runtime_event_target() else { return; }; let init = CustomEventInit::new(); init.set_detail(&detail_value); if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) { let _ = target.dispatch_event(&event); } } fn dispatch_custom_event_to_target(target: &EventTarget, event_name: &'static str, payload: &T) where T: Serialize, { let envelope = BridgeEnvelope { protocol: PROTOCOL, runtime: RUNTIME_NAME, version: RUNTIME_VERSION, source: EVENT_PREFIX, event: event_name, payload, }; let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else { return; }; let init = CustomEventInit::new(); init.set_detail(&detail_value); init.set_bubbles(true); if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) { let _ = target.dispatch_event(&event); } } fn dispatch_ready_event_to_target(target: &EventTarget, payload: &ReadyPayload) { dispatch_custom_event_to_target(target, READY_EVENT, payload); } fn dispatch_change_event(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_to_target(target: &EventTarget, payload: &SelectionPayload) { dispatch_custom_event_to_target(target, SELECTION_EVENT, payload); } fn register_unmount_handle( id: u32, target: EventTarget, listener: Closure, handle: UnmountHandle, ) { let mut listeners = vec![MountedRuntimeListener { target, listener }]; PENDING_RUNTIME_LISTENERS.with(|registry| { if let Some(mut pending) = registry.borrow_mut().remove(&id) { listeners.append(&mut pending); } }); MOUNTED_HANDLES.with(|registry| { registry.borrow_mut().insert( id, MountedRuntime { listeners, mount_handle: Box::new(handle), }, ); }); } fn register_runtime_listener(id: u32, target: EventTarget, listener: Closure) { MOUNTED_HANDLES.with(|registry| { if let Some(runtime) = registry.borrow_mut().get_mut(&id) { runtime .listeners .push(MountedRuntimeListener { target, listener }); } else { PENDING_RUNTIME_LISTENERS.with(|pending_registry| { pending_registry .borrow_mut() .entry(id) .or_default() .push(MountedRuntimeListener { target, listener }); }); } }); } fn take_unmount_handle(id: u32) -> Option { PENDING_RUNTIME_LISTENERS.with(|registry| { registry.borrow_mut().remove(&id); }); MOUNTED_HANDLES.with(|registry| registry.borrow_mut().remove(&id)) } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellAction { id: String, label: String, #[serde(default)] long_label: Option, icon: String, #[serde(default)] icon_key: Option, #[serde(default)] priority: Option, #[serde(default)] overflow_group: Option, #[serde(default)] cluster: Option, disabled: bool, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellToolbarGroup { id: String, label: String, actions: Vec, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellSidebarPanel { id: String, kind: String, label: String, icon: String, active: bool, body_title: String, body_caption: String, #[serde(default)] options: Vec, #[serde(default)] body_items: Vec, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellSidebarOption { id: String, label: String, action_id: Option, value: Value, control_type: String, #[serde(default)] preview: Option, #[serde(default)] description: Option, #[serde(default)] readonly: bool, compat_path: Option, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellNavigator { word_count: u32, node_count: u32, zoom_percent: u32, readonly: bool, #[serde(default)] minimap_open: bool, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellToolbarOverflowState { available_width: Option, #[serde(default)] visible_action_ids: Vec, #[serde(default)] overflow_action_ids: Vec, #[serde(default)] more_open: bool, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellFullscreenState { mode: String, is_fullscreen: bool, target: String, #[serde(default = "default_true")] api_available: bool, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellSidebarState { trigger_visible: bool, panel_open: bool, active_panel_id: Option, drawer_width: u32, collapsed_by_toggle: bool, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellNavigatorState { search_open: bool, minimap_open: bool, readonly: bool, zoom_percent: u32, mouse_behavior: String, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellInteractionState { chrome_visibility: String, toolbar_overflow: MindmapShellToolbarOverflowState, fullscreen: MindmapShellFullscreenState, sidebar: MindmapShellSidebarState, navigator: MindmapShellNavigatorState, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellOptions { mindmap_id: String, toolbar_groups: Vec, sidebar_panels: Vec, navigator: MindmapShellNavigator, shell: MindmapShellInteractionState, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellActionPayload<'a> { action_id: &'a str, #[serde(skip_serializing_if = "Option::is_none")] value: Option<&'a Value>, #[serde(skip_serializing_if = "Option::is_none")] compat_path: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] source: Option<&'a str>, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellPanelPayload<'a> { event: &'a str, #[serde(skip_serializing_if = "Option::is_none")] panel_id: Option<&'a str>, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellMinimapPayload { open: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellZoomPayload { percent: u32, } fn default_true() -> bool { true } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct MindmapShellToolbarOverflowPayload { more_open: bool, } fn dispatch_mindmap_shell_event(target: &EventTarget, event_name: &'static str, payload: &T) where T: Serialize, { let Ok(detail_value) = serde_wasm_bindgen::to_value(payload) 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 render_mindmap_toolbar_action( action: MindmapShellAction, class_name: &'static str, source: &'static str, action_target: EventTarget, ) -> impl IntoView { let action_id = action.id.clone(); let action_label_title = action .long_label .clone() .filter(|label| !label.is_empty()) .unwrap_or_else(|| action.label.clone()); let action_label_aria = action_label_title.clone(); let action_label_text = action.label.clone(); let action_icon_text = action.icon.clone(); let action_icon_key = action.icon_key.clone().unwrap_or_default(); let action_overflow_group = action.overflow_group.clone().unwrap_or_default(); let action_cluster = action.cluster.clone().unwrap_or_default(); let action_priority = action.priority.unwrap_or(u32::MAX).to_string(); let action_disabled = action.disabled; view! { } } fn render_mindmap_sidebar_option( option: MindmapShellSidebarOption, action_target: EventTarget, ) -> impl IntoView { let option_id = option.id.clone(); let option_label = option.label.clone(); let option_control_type = option.control_type.clone(); let action_id = option.action_id.clone(); let option_value = option.value.clone(); let compat_path = option.compat_path.clone(); let option_preview = option.preview.clone().unwrap_or_default(); let option_description = option.description.clone().unwrap_or_default(); let option_readonly = option.readonly; let option_action_id_attr = action_id.clone().unwrap_or_default(); if option_readonly || action_id.is_none() { return view! {
{option_label} {if option_description.is_empty() { ().into_any() } else { view! { {option_description} }.into_any() }}
} .into_any(); } let button_class = if option_control_type == "layoutCard" { "mnote-mindmap-side-option mnote-mindmap-side-option-layout-card" } else if option_control_type == "swatch" { "mnote-mindmap-side-option mnote-mindmap-side-option-swatch" } else if option_control_type == "treeItem" { "mnote-mindmap-side-option mnote-mindmap-side-option-tree-item" } else { "mnote-mindmap-side-option" }; view! { } .into_any() } #[component] 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(); let shell = options.shell.clone(); let minimap_open = shell.navigator.minimap_open; let search_open = shell.navigator.search_open; let sidebar_panel_open = shell.sidebar.panel_open; let sidebar_trigger_visible = shell.sidebar.trigger_visible; let active_panel_id = shell.sidebar.active_panel_id.clone(); let sidebar_drawer_width = shell.sidebar.drawer_width.to_string(); let chrome_visibility = shell.chrome_visibility.clone(); let fullscreen_mode = shell.fullscreen.mode.clone(); let fullscreen_target = shell.fullscreen.target.clone(); let navigator_mouse_behavior = shell.navigator.mouse_behavior.clone(); let toolbar_available_width = shell .toolbar_overflow .available_width .map(|width| width.round().to_string()) .unwrap_or_else(|| "".to_string()); let chrome_visible = shell.chrome_visibility == "visible"; let toolbar_visible_actions = shell.toolbar_overflow.visible_action_ids.join(","); let toolbar_overflow_actions = shell.toolbar_overflow.overflow_action_ids.join(","); let sidebar_drawer_width_value = shell.sidebar.drawer_width; let navigator_right_offset = if chrome_visible && sidebar_panel_open && sidebar_trigger_visible { sidebar_drawer_width_value + 112 } else if chrome_visible && sidebar_trigger_visible { 108 } else { 28 }; let navigator_right_style = format!("right: {}px;", navigator_right_offset); let minimap_right_style = format!("right: {}px;", navigator_right_offset); let mut primary_toolbar_actions: Vec = Vec::new(); let mut overflow_toolbar_actions: Vec = Vec::new(); let mut file_toolbar_actions: Vec = Vec::new(); for group in toolbar_groups.into_iter() { match group.id.as_str() { "overflow" => overflow_toolbar_actions.extend(group.actions), "file" => file_toolbar_actions.extend(group.actions), _ => primary_toolbar_actions.extend(group.actions), } } primary_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX)); overflow_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX)); file_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX)); let has_toolbar_overflow = !overflow_toolbar_actions.is_empty(); let toolbar_more_open = shell.toolbar_overflow.more_open && has_toolbar_overflow; let action_target = event_target.clone(); let panel_target = event_target; view! {
{if chrome_visible { view! {
{primary_toolbar_actions .into_iter() .map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone())) .collect_view()}
{if has_toolbar_overflow { let overflow_toggle_target = action_target.clone(); let overflow_menu_actions = overflow_toolbar_actions.clone(); view! {
{if toolbar_more_open { view! { }.into_any() } else { ().into_any() }}
}.into_any() } else { ().into_any() }}
{file_toolbar_actions .into_iter() .map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone())) .collect_view()}
}.into_any() } else { ().into_any() }} {if chrome_visible { view! { }.into_any() } else { ().into_any() }}
{format!("字数 {}", navigator.word_count)} {format!("节点 {}", navigator.node_count)}
{if chrome_visible { view! {
{if search_open { view! { }.into_any() } else { ().into_any() }}
() { dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ZOOM_EVENT, &MindmapShellZoomPayload { percent }); } else if let Some(target) = event.target() { if let Ok(input) = target.dyn_into::() { input.set_value(&format!("{}%", navigator.zoom_percent)); } } } } on:keydown=move |event: ev::KeyboardEvent| { if event.key() == "Escape" { if let Some(target) = event.target() { if let Ok(input) = target.dyn_into::() { input.set_value(&format!("{}%", navigator.zoom_percent)); input.blur().ok(); } } } } />
}.into_any() } else { ().into_any() }} {if minimap_open { view! {
}.into_any() } else { ().into_any() }}
} } fn take_mindmap_shell_handle(id: u32) -> Option { MOUNTED_MINDMAP_SHELLS.with(|registry| registry.borrow_mut().remove(&id)) } #[wasm_bindgen] pub fn mount_mindmap_shell(container: Element, options: JsValue) -> Result { console_error_panic_hook::set_once(); let options = serde_wasm_bindgen::from_value::(options)?; let target = container .dyn_into::() .map_err(|_| JsValue::from_str("mindmap shell mount 目标必须是 HTML 元素"))?; let mount_id = next_mount_id(); let event_target: EventTarget = target.clone().into(); let handle = mount_to(target, move || { view! { } }); MOUNTED_MINDMAP_SHELLS.with(|registry| { registry.borrow_mut().insert( mount_id, MountedMindmapShell { mount_handle: Box::new(handle), }, ); }); Ok(mount_id) } #[wasm_bindgen] pub fn unmount_mindmap_shell(mount_id: u32) -> Result<(), JsValue> { if take_mindmap_shell_handle(mount_id).is_some() { Ok(()) } else { Err(JsValue::from_str("找不到对应的 mindmap shell 挂载句柄")) } } #[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); } drop(handle); Ok(()) } else { Err(JsValue::from_str("找不到对应的挂载句柄")) } } fn mount_app_into(target: HtmlElement, options: MountOptions, mode: RuntimeDeliveryMode) -> u32 { let mount_id = next_mount_id(); let context_target: EventTarget = target.clone().into(); let mount_options = RuntimeMountOptions { options: options.clone(), mode, }; set_runtime_mount_options(Some(mount_options.clone())); set_runtime_mount_context(Some(RuntimeMountContext { id: mount_id, target: context_target.clone(), mode, })); let handle = mount_to(target, move || { view! { } }); let command_listener = build_command_listener(mount_id); let _ = context_target .add_event_listener_with_callback(COMMAND_EVENT, command_listener.as_ref().unchecked_ref()); register_unmount_handle(mount_id, context_target, command_listener, handle); mount_id } fn build_command_listener(mount_id: u32) -> Closure { Closure::::wrap(Box::new(move |event: Event| { let Some(custom_event) = event.dyn_ref::() else { return; }; let detail = custom_event.detail(); let Ok(envelope) = serde_wasm_bindgen::from_value::(detail) else { return; }; if envelope.protocol.as_deref() != Some(PROTOCOL) { return; } let Some(payload) = envelope.payload else { return; }; let Some(command_kind) = HostCommandKind::from_payload(&payload) else { return; }; let _ = mount_id; let _ = command_kind; })) } fn default_title() -> String { "Leptos Tiptap 主编辑器 P0".to_string() } 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 url_requests_embedded_mode() -> bool { window() .and_then(|win| win.location().search().ok()) .map(|search| search.contains("embedded=1")) .unwrap_or(false) } fn resolve_runtime_delivery_mode( explicit_mode: Option, url_embedded: bool, ) -> RuntimeDeliveryMode { if let Some(mode) = explicit_mode { return mode; } if url_embedded { RuntimeDeliveryMode::Embedded } else { RuntimeDeliveryMode::Standalone } } 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 content_column_width(stage_rect_width: f64) -> f64 { let available = (stage_rect_width - (CONTENT_COLUMN_HORIZONTAL_PADDING * 2.0)).max(0.0); available.min(CONTENT_COLUMN_MAX_WIDTH) } fn content_column_left(stage_rect_width: f64) -> f64 { let column_width = content_column_width(stage_rect_width); ((stage_rect_width - column_width) / 2.0).max(0.0) } fn content_text_left(stage_rect_width: f64) -> f64 { content_column_left(stage_rect_width) + CONTENT_COLUMN_HORIZONTAL_PADDING } 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 block_label_for_element(block: &Element) -> String { match block.get_attribute("data-type").as_deref() { Some("taskList") => "Todo 列表".to_string(), Some("taskItem") => "Todo 项".to_string(), _ => match block.tag_name().as_str() { "H1" => "一级标题".to_string(), "H2" => "二级标题".to_string(), "H3" => "三级标题".to_string(), "P" => "段落".to_string(), "UL" => "无序列表".to_string(), "OL" => "有序列表".to_string(), "BLOCKQUOTE" => "引用块".to_string(), "PRE" => "代码块".to_string(), "HR" => "分割线".to_string(), other => format!("块节点 {other}"), }, } } fn current_target_html_element(event: &WheelEvent) -> Option { event.current_target()?.dyn_into::().ok() } fn trap_scroll_inside_menu(event: &WheelEvent) { event.prevent_default(); event.stop_propagation(); let Some(menu) = current_target_html_element(event) else { return; }; let current = f64::from(menu.scroll_top()); let max_scroll = f64::from((menu.scroll_height() - menu.client_height()).max(0)); let next = (current + event.delta_y()).clamp(0.0, max_scroll); menu.set_scroll_top(next.round() as i32); } fn hovered_block_from_target(target: web_sys::EventTarget) -> Option { let root = editor_root_element()?; let element = target_element(target)?; let index = direct_block_index_from_element(&element, &root, HANDLE_SHELL_SELECTOR)?; block_state_from_index(index) } fn hovered_block_from_selection() -> Option { let selection = window().and_then(|win| win.get_selection().ok().flatten())?; let anchor_node = selection.anchor_node()?; let element = anchor_node .dyn_ref::() .cloned() .or_else(|| anchor_node.parent_element())?; let root = editor_root_element()?; let block = direct_block_from_element(element, &root)?; let index = top_level_block_index(&root, &block)?; block_state_from_index(index) } fn block_state_from_index(index: usize) -> Option { let root = editor_root_element()?; let stage = editor_stage_element()?; let block = root.children().item(index as u32)?; let block_rect = block.get_bounding_client_rect(); let stage_rect = stage.get_bounding_client_rect(); let block_height = if block_rect.height() > 0.0 { block_rect.height() } else { 28.0 }; Some(HoveredBlockState { index, label: block_label_for_element(&block), top: block_rect.top() - stage_rect.top(), height: block_height, }) } fn table_selection_overlay_style( anchor: &TableOverlayAnchor, selection: &TableSelectionOverlayState, ) -> String { match selection.kind { TableSelectionKind::Row => { let row_height = anchor.height / anchor.rows.max(1) as f64; format!( "left:0;top:{:.1}px;width:{:.1}px;height:{:.1}px;", row_height * selection.index as f64, anchor.width, row_height ) } TableSelectionKind::Column => { let col_width = anchor.width / anchor.cols.max(1) as f64; format!( "left:{:.1}px;top:0;width:{:.1}px;height:{:.1}px;", col_width * selection.index as f64, col_width, anchor.height ) } } } fn prosemirror_node_size(node: &Value) -> Option { match node.get("type").and_then(Value::as_str) { Some("text") => Some( node.get("text") .and_then(Value::as_str) .map(|text| text.encode_utf16().count() as u32) .unwrap_or(0), ), Some("hardBreak") | Some("horizontalRule") => Some(1), _ => { let Some(children) = node.get("content").and_then(Value::as_array) else { return Some(match node.get("type").and_then(Value::as_str) { Some("paragraph") | Some("heading") | Some("blockquote") | Some("codeBlock") | Some("bulletList") | Some("orderedList") | Some("taskList") | Some("listItem") | Some("taskItem") | Some("table") | Some("tableRow") | Some("tableCell") | Some("tableHeader") => 2, _ => 1, }); }; let content_size = children.iter().try_fold(0_u32, |acc, child| { prosemirror_node_size(child).map(|size| acc + size) })?; Some(content_size + 2) } } } fn nested_edge_child_size(node: &Value, at_start: bool) -> Option { let outer = node .get("content") .and_then(Value::as_array) .and_then(|children| { if at_start { children.first() } else { children.last() } })?; let inner = outer .get("content") .and_then(Value::as_array) .and_then(|children| { if at_start { children.first() } else { children.last() } })?; prosemirror_node_size(inner) } fn top_level_block_range(document: &Value, index: usize) -> Option { let content = document.get("content").and_then(Value::as_array)?; let mut position = 0_u32; for (current_index, node) in content.iter().enumerate() { let node_size = prosemirror_node_size(node)?; if current_index == index { let from = position + nested_edge_child_size(node, true).unwrap_or(1); let mut to = position + node_size.saturating_sub(nested_edge_child_size(node, false).unwrap_or(1)); if to < from { to = from; } return Some(TiptapRange { from, to }); } position += node_size; } None } fn slash_menu_anchor_style() -> String { const MENU_WIDTH: f64 = 316.0; const MENU_HEIGHT: f64 = 430.0; const GAP: f64 = 8.0; let viewport_width = window() .and_then(|win| win.inner_width().ok()) .and_then(|value| value.as_f64()) .unwrap_or(1024.0) .max(320.0); let viewport_height = window() .and_then(|win| win.inner_height().ok()) .and_then(|value| value.as_f64()) .unwrap_or(768.0) .max(240.0); let selection_rect = window() .and_then(|win| win.get_selection().ok().flatten()) .and_then(|selection| selection.get_range_at(0).ok()) .map(|range| range.get_bounding_client_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_left = raw_left.clamp(GAP, (viewport_width - MENU_WIDTH - GAP).max(GAP)); let below_limit = viewport_height - GAP; let clamped_top = if raw_top + MENU_HEIGHT > below_limit { (raw_anchor_top - MENU_HEIGHT - GAP).clamp(GAP, below_limit - 120.0) } else { raw_top.clamp(GAP, below_limit - 120.0) }; format!("top:{clamped_top:.1}px;left:{clamped_left:.1}px;") } fn image_toolbar_anchor_from_image(image: &Element) -> Option { let stage = editor_stage_element()?; let rect = image.get_bounding_client_rect(); if rect.width() <= 0.0 && rect.height() <= 0.0 { return None; } let stage_rect = stage.get_bounding_client_rect(); let align = image .get_attribute("data-align") .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "left".to_string()); Some(ImageToolbarAnchor { top: (rect.top() - stage_rect.top()).max(60.0), left: rect.left() + (rect.width() / 2.0) - stage_rect.left(), align, }) } fn image_element_from_target(target: web_sys::EventTarget) -> Option { let element = target_element(target)?; if element.tag_name().eq_ignore_ascii_case("img") && element .closest(".editor-surface .ProseMirror") .ok() .flatten() .is_some() { return Some(element); } element .closest(".editor-surface .ProseMirror img[src]") .ok() .flatten() } fn pointer_in_handle_corridor(client_x: i32, client_y: i32, block: &HoveredBlockState) -> bool { let Some(stage) = editor_stage_element() else { return false; }; let Some(root) = editor_root_element() else { return false; }; let Some(block_element) = root.children().item(block.index as u32) else { return false; }; let stage_rect = stage.get_bounding_client_rect(); let block_rect = block_element.get_bounding_client_rect(); let handle_rect = window() .and_then(|win| win.document()) .and_then(|document| { document .query_selector(HANDLE_SHELL_SELECTOR) .ok() .flatten() }) .map(|handle| handle.get_bounding_client_rect()); let block_left = block_rect.left() - stage_rect.left(); let block_top = block_rect.top() - stage_rect.top(); let block_bottom = block_rect.bottom() - stage_rect.top(); let handle_left = handle_rect .as_ref() .map(|rect| rect.left() - stage_rect.left()) .unwrap_or_else(|| handle_lane_left(stage_rect.width())); let handle_right = handle_rect .as_ref() .map(|rect| rect.right() - stage_rect.left()) .unwrap_or_else(|| handle_left + HANDLE_TRIGGER_WIDTH); pointer_in_handle_corridor_geometry( client_x, client_y, &HandleCorridorGeometry { stage_left: stage_rect.left(), stage_top: stage_rect.top(), block_left, block_top, block_bottom, handle_left, handle_right, }, ) } fn drop_indicator_from_target( target: web_sys::EventTarget, client_y: i32, ) -> Option { let hovered = hovered_block_from_target(target)?; let root = editor_root_element()?; let block = root.children().item(hovered.index as u32)?; let stage = editor_stage_element()?; Some(drop_indicator_from_block_rects( hovered.index, &block, &stage, client_y, )) } fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option { let target = event_target_from_point(client_x, client_y)?; drop_indicator_from_target(target, client_y) } fn document_content_mut(document: &mut Value) -> Result<&mut Vec, String> { document .get_mut("content") .and_then(Value::as_array_mut) .ok_or_else(|| "文档 JSON 缺少顶层 content 数组".to_string()) } fn next_mindmap_id() -> String { let now = js_sys::Date::new_0(); format!( "思维导图{:02}{:02}{:02}.json", now.get_hours(), now.get_minutes(), now.get_seconds() ) } fn apply_html_update( editor: TiptapEditorHandle, persisted_identity: &PersistedDocumentIdentity, next_html: String, document_id: ReadSignal>, workspace_id: ReadSignal>, dirty_count: ReadSignal, set_dirty_count: WriteSignal, set_html_output: WriteSignal, set_document_json: WriteSignal, set_json_output: WriteSignal, title: ReadSignal, hovered_block: ReadSignal>, editor_focused: ReadSignal, slash_open: ReadSignal, turn_into_open: ReadSignal, color_menu_open: ReadSignal, more_menu_open: ReadSignal, revision: ReadSignal>, conflict_detection_key: ReadSignal>, read_only: ReadSignal, set_command_feedback: WriteSignal, success_message: impl Into, ) { let success_message = success_message.into(); match editor.set_content(TiptapContent::html(next_html)) { Ok(()) => { set_dirty_count.update(|count| *count += 1); let (html, snapshot, json_text) = read_editor_snapshot(editor); 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(), ); match persist_document_state( persisted_identity, &title.get_untracked(), &snapshot, Some(html), ) { Ok(()) => { set_command_feedback.set(format!("{success_message},并已写入本地草稿")); } Err(err) => { set_command_feedback.set(format!("{success_message},但本地保存失败:{err}")); } } } Err(err) => { set_command_feedback.set(format!("更新文档失败:{err}")); } } } fn top_level_block_boundary_position(document: &Value, index: usize, before: bool) -> Option { let content = document.get("content").and_then(Value::as_array)?; let mut position = 0_u32; for (current_index, node) in content.iter().enumerate() { let node_size = prosemirror_node_size(node)?; if current_index == index { return Some(if before { position } else { position + node_size }); } position += node_size; } None } fn insert_editor_paragraph_relative_to_block( editor: TiptapEditorHandle, index: usize, before: bool, ) -> Result { let document = editor .get_json() .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; let position = top_level_block_boundary_position(&document, index, before) .ok_or_else(|| format!("找不到第 {} 个块的插入位置", index + 1))?; editor .insert_content_at( position, TiptapContent::json(json!({ "type": "paragraph" })), Some(TiptapInsertContentOptions { update_selection: Some(true), ..Default::default() }), ) .map_err(|err| format!("插入新块失败:{err}"))?; let new_index = if before { index } else { index + 1 }; focus_top_level_block_start(editor, new_index)?; Ok(new_index) } fn focus_top_level_block_start(editor: TiptapEditorHandle, index: usize) -> Result<(), String> { editor .focus() .map_err(|err| format!("聚焦编辑器失败:{err}"))?; if let (Some(root), Some(document), Some(selection)) = ( editor_root_element(), window().and_then(|win| win.document()), window().and_then(|win| win.get_selection().ok().flatten()), ) { if let Some(block) = root.children().item(index as u32) { let range = document .create_range() .map_err(|err| format!("创建新块光标失败:{err:?}"))?; range .select_node_contents(block.unchecked_ref::()) .map_err(|err| format!("选择新块失败:{err:?}"))?; range.collapse_with_to_start(true); selection .remove_all_ranges() .map_err(|err| format!("清理旧选区失败:{err:?}"))?; selection .add_range(&range) .map_err(|err| format!("写入新块选区失败:{err:?}"))?; return Ok(()); } } let document = editor .get_json() .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; let range = top_level_block_range(&document, index) .ok_or_else(|| format!("找不到第 {} 个块的选择范围", index + 1))?; editor .set_text_selection(range.from) .map_err(|err| format!("定位新块失败:{err}")) } 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 current_block_info_from_index(index: Option) -> CurrentBlockInfo { index .map(|value| CurrentBlockInfo { index: Some(value), block_id: runtime_block_id_from_index(value), }) .unwrap_or(CurrentBlockInfo { index: None, block_id: None, }) } 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( 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(&state); dispatch_status_event(&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 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_text_color(editor: TiptapEditorHandle, color: &str) -> Result<&'static str, String> { editor .set_color(TiptapColorAttributes { color: color.to_string(), }) .map(|_| "已更新文字颜色") .map_err(|err| format!("命令执行失败:{err}")) } fn clear_text_color(editor: TiptapEditorHandle) -> Result<&'static str, String> { editor .unset_color() .map(|_| "已清除文字颜色") .map_err(|err| format!("命令执行失败:{err}")) } fn apply_highlight_color( editor: TiptapEditorHandle, color: Option<&str>, ) -> Result<&'static str, String> { match color { Some(color) => editor .set_highlight(Some(TiptapHighlightAttributes { color: Some(color.to_string()), })) .map(|_| "已更新背景色") .map_err(|err| format!("命令执行失败:{err}")), None => editor .unset_highlight() .map(|_| "已清除背景色") .map_err(|err| format!("命令执行失败:{err}")), } } fn apply_text_align( editor: TiptapEditorHandle, alignment: TiptapTextAlign, ) -> Result<&'static str, String> { let message = match alignment { TiptapTextAlign::Left => "已切到左对齐", TiptapTextAlign::Center => "已切到居中", TiptapTextAlign::Right => "已切到右对齐", TiptapTextAlign::Justify => "已切到两端对齐", }; editor .set_text_align(alignment) .map(|_| message) .map_err(|err| format!("命令执行失败:{err}")) } fn apply_image_align( editor: TiptapEditorHandle, align: &'static str, ) -> Result<&'static str, String> { let _ = editor.focus(); let mut attrs = leptos_tiptap::TiptapAttributes::new(); attrs.insert("data-align", align); editor .update_attributes(TiptapSchemaTarget::Node(TiptapNodeName::Image), attrs) .map(|_| "已更新图片对齐") .map_err(|err| format!("命令执行失败:{err}")) } #[derive(Clone, Copy)] enum TableToolbarAction { AddRowAfter, AddColumnAfter, DeleteRow, DeleteColumn, ClearCell, DeleteTable, } #[derive(Clone, Copy)] enum TableOptionAction { ToggleHeaderRow, ToggleHeaderColumn, ToggleHiddenBorders, } impl TableToolbarAction { fn id(self) -> &'static str { match self { Self::AddRowAfter => "add-row-after", Self::AddColumnAfter => "add-column-after", Self::DeleteRow => "delete-row", Self::DeleteColumn => "delete-column", Self::ClearCell => "clear-cell", Self::DeleteTable => "delete-table", } } fn icon(self) -> &'static str { match self { Self::AddRowAfter => "+R", Self::AddColumnAfter => "+C", Self::DeleteRow => "-R", Self::DeleteColumn => "-C", Self::ClearCell => "⌫", Self::DeleteTable => "×", } } fn title(self) -> &'static str { match self { Self::AddRowAfter => "下方插入行", Self::AddColumnAfter => "右侧插入列", Self::DeleteRow => "删除当前行", Self::DeleteColumn => "删除当前列", Self::ClearCell => "清空当前单元格", Self::DeleteTable => "删除表格", } } } const TABLE_TOOLBAR_ACTIONS: [TableToolbarAction; 6] = [ TableToolbarAction::AddRowAfter, TableToolbarAction::AddColumnAfter, TableToolbarAction::ClearCell, TableToolbarAction::DeleteRow, TableToolbarAction::DeleteColumn, TableToolbarAction::DeleteTable, ]; impl TableOptionAction { fn id(self) -> &'static str { match self { Self::ToggleHeaderRow => "toggle-header-row", Self::ToggleHeaderColumn => "toggle-header-column", Self::ToggleHiddenBorders => "toggle-hidden-borders", } } fn title(self) -> &'static str { match self { Self::ToggleHeaderRow => "标题行", Self::ToggleHeaderColumn => "标题列", Self::ToggleHiddenBorders => "隐藏边框线", } } } const TABLE_OPTION_ACTIONS: [TableOptionAction; 3] = [ TableOptionAction::ToggleHeaderRow, TableOptionAction::ToggleHeaderColumn, TableOptionAction::ToggleHiddenBorders, ]; fn run_table_toolbar_action( editor: TiptapEditorHandle, action: TableToolbarAction, ) -> Result<&'static str, String> { let result = match action { TableToolbarAction::AddRowAfter => editor.add_table_row_after(), TableToolbarAction::AddColumnAfter => editor.add_table_column_after(), TableToolbarAction::DeleteRow => editor.delete_table_row(), TableToolbarAction::DeleteColumn => editor.delete_table_column(), TableToolbarAction::ClearCell => editor.clear_table_cell(), TableToolbarAction::DeleteTable => editor.delete_table(), }; result .map(|_| action.title()) .map_err(|err| format!("命令执行失败:{err}")) } fn run_table_option_action( editor: TiptapEditorHandle, action: TableOptionAction, ) -> Result<&'static str, String> { let result = match action { TableOptionAction::ToggleHeaderRow => editor.toggle_table_header_row(), TableOptionAction::ToggleHeaderColumn => editor.toggle_table_header_column(), TableOptionAction::ToggleHiddenBorders => editor.toggle_table_hidden_borders(), }; result .map(|_| action.title()) .map_err(|err| format!("命令执行失败:{err}")) } fn first_table_node(value: &Value) -> Option<&Value> { if value.get("type").and_then(Value::as_str) == Some("table") { return Some(value); } if let Some(table) = value .get("props") .and_then(|props| props.get("tiptapTable")) .and_then(first_table_node) { return Some(table); } for key in ["content", "children"] { if let Some(children) = value.get(key).and_then(Value::as_array) { if let Some(table) = children.iter().find_map(first_table_node) { return Some(table); } } } None } fn table_option_checked(document: &Value, action: TableOptionAction) -> bool { let Some(table) = first_table_node(document) else { return false; }; match action { TableOptionAction::ToggleHiddenBorders => table .get("attrs") .and_then(|attrs| attrs.get("hiddenBorders")) .and_then(Value::as_bool) .unwrap_or(false), TableOptionAction::ToggleHeaderRow => table .get("content") .and_then(Value::as_array) .and_then(|rows| rows.first()) .and_then(|row| row.get("content")) .and_then(Value::as_array) .map(|cells| { !cells.is_empty() && cells .iter() .all(|cell| cell.get("type").and_then(Value::as_str) == Some("tableHeader")) }) .unwrap_or(false), TableOptionAction::ToggleHeaderColumn => table .get("content") .and_then(Value::as_array) .map(|rows| { !rows.is_empty() && rows.iter().all(|row| { row.get("content") .and_then(Value::as_array) .and_then(|cells| cells.first()) .and_then(|cell| cell.get("type")) .and_then(Value::as_str) == Some("tableHeader") }) }) .unwrap_or(false), } } fn run_slash_action( editor: TiptapEditorHandle, action: SlashActionKind, ) -> Result<&'static str, String> { let result = match action { SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi => return Ok("AI 命令入口已打开"), SlashActionKind::Paragraph => editor.set_paragraph(), SlashActionKind::Heading1 => editor.toggle_heading(TiptapHeadingLevel::H1), SlashActionKind::Heading2 => editor.toggle_heading(TiptapHeadingLevel::H2), SlashActionKind::Heading3 => editor.toggle_heading(TiptapHeadingLevel::H3), SlashActionKind::Heading4 => editor.toggle_heading(TiptapHeadingLevel::H4), SlashActionKind::BulletList => editor.toggle_bullet_list(), SlashActionKind::OrderedList => editor.toggle_ordered_list(), SlashActionKind::Todo | SlashActionKind::AdvancedTodo => editor.toggle_task_list(), SlashActionKind::Quote => editor.toggle_blockquote(), SlashActionKind::CodeBlock => editor.toggle_code_block(Some(TiptapCodeBlockAttributes { language: Some("rust".into()), })), SlashActionKind::Divider => editor.set_horizontal_rule(), SlashActionKind::SimpleTable => { let _ = editor.focus(); editor.insert_table(4, 3, false) } SlashActionKind::Mindmap => { editor.insert_content( TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)), None, ) } SlashActionKind::Image => { dispatch_editor_upload_request("image", "image/*")?; return Ok("已打开图片上传"); } SlashActionKind::UploadAttachment => { dispatch_editor_upload_request("attachment", "")?; return Ok("已打开附件上传"); } SlashActionKind::Toc => editor.insert_toc_node(TiptapTocNodeAttrs { top_offset: Some(0), max_show_count: Some(20), show_title: Some(true), }), }; result .map(|_| match action { SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi => "AI 命令入口已打开", SlashActionKind::Paragraph => "已切到文本", SlashActionKind::Heading1 => "已切到一级标题", SlashActionKind::Heading2 => "已切到二级标题", SlashActionKind::Heading3 => "已切到三级标题", SlashActionKind::Heading4 => "已切到四级标题", SlashActionKind::BulletList => "已切到无序列表", SlashActionKind::OrderedList => "已切到有序列表", SlashActionKind::Todo | SlashActionKind::AdvancedTodo => "已切到待办列表", SlashActionKind::Quote => "已切到引述文字", SlashActionKind::CodeBlock => "已切到代码块", SlashActionKind::Divider => "已插入分割线", SlashActionKind::SimpleTable => "已插入简单表格", SlashActionKind::Mindmap => "已插入思维导图", SlashActionKind::Image => "已打开图片上传", SlashActionKind::UploadAttachment => "已打开附件上传", SlashActionKind::Toc => "已插入页面目录", }) .map_err(|err| format!("命令执行失败:{err}")) } fn run_block_turn_into_action( editor: TiptapEditorHandle, block_index: usize, action: SlashActionKind, ) -> Result<&'static str, String> { let document = editor .get_json() .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; let range = top_level_block_range(&document, block_index) .ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?; match action { SlashActionKind::SimpleTable => { editor .set_text_selection(range) .map_err(|err| format!("选中当前块失败:{err}"))?; editor .insert_table(4, 3, false) .map(|_| "已把当前块转成简单表格") .map_err(|err| format!("命令执行失败:{err}")) } SlashActionKind::Toc => { editor .set_text_selection(range) .map_err(|err| format!("选中当前块失败:{err}"))?; editor .insert_toc_node(TiptapTocNodeAttrs { top_offset: Some(0), max_show_count: Some(20), show_title: Some(true), }) .map(|_| "已把当前块转成页面目录") .map_err(|err| format!("命令执行失败:{err}")) } SlashActionKind::Divider => editor .insert_content_at( range, TiptapContent::json(json!({ "type": "horizontalRule" })), None, ) .map(|_| "已把当前块转成分割线") .map_err(|err| format!("命令执行失败:{err}")), SlashActionKind::Mindmap => editor .insert_content_at( range, TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)), None, ) .map(|_| "已把当前块转成思维导图") .map_err(|err| format!("命令执行失败:{err}")), _ => { editor .set_text_selection(range) .map_err(|err| format!("选中当前块失败:{err}"))?; editor .clear_nodes() .map_err(|err| format!("清理当前块样式失败:{err}"))?; let message = run_slash_action(editor, action)?; let _ = editor.focus(); let _ = editor.select_textblock_end(); Ok(message) } } } fn heading_level_from_u8(level: u8) -> TiptapHeadingLevel { match level { 1 => TiptapHeadingLevel::H1, 2 => TiptapHeadingLevel::H2, 3 => TiptapHeadingLevel::H3, 4 => TiptapHeadingLevel::H4, 5 => TiptapHeadingLevel::H5, _ => TiptapHeadingLevel::H6, } } fn run_block_turn_into_folded_heading_action( editor: TiptapEditorHandle, block_index: usize, level: u8, ) -> Result<&'static str, String> { let document = editor .get_json() .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; let range = top_level_block_range(&document, block_index) .ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?; editor .set_text_selection(range) .map_err(|err| format!("选中当前块失败:{err}"))?; editor .clear_nodes() .map_err(|err| format!("清理当前块样式失败:{err}"))?; editor .set_heading_with_collapsed(heading_level_from_u8(level), true) .map_err(|err| format!("命令执行失败:{err}"))?; let mut attrs = leptos_tiptap::TiptapAttributes::new(); attrs.insert("collapsed", true); editor .update_attributes(TiptapSchemaTarget::Node(TiptapNodeName::Heading), attrs) .map_err(|err| format!("写入折叠属性失败:{err}"))?; let _ = editor.focus(); let _ = editor.select_textblock_end(); Ok(match level { 1 => "已切到折叠主标题", 2 => "已切到折叠大标题", 3 => "已切到折叠中标题", 4 => "已切到折叠小标题", _ => "已切到折叠标题", }) } fn normalized_page_block_title(text: &str) -> String { let trimmed = text.trim(); if trimmed.is_empty() { "未命名页面".to_string() } else { trimmed.to_string() } } #[derive(Debug, Deserialize)] struct TreeCommandHttpResponse { result: Option, message: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct TreeCommandResult { document_id: Option, workspace_id: Option, title: Option, parent_id: Option, } fn dispatch_tree_local_command(body: &Value, result: &Value) { let Some(win) = window() else { return; }; let Ok(detail) = serde_wasm_bindgen::to_value(&json!({ "body": body, "result": result, })) else { return; }; let init = CustomEventInit::new(); init.set_detail(&detail); if let Ok(event) = CustomEvent::new_with_event_init_dict("tree:local-command", &init) { let _ = win.dispatch_event(&event); } } async fn create_child_page_via_tree_command( parent_id: String, workspace_id: Option, title: String, ) -> Result { let body = json!({ "action": "create", "workspaceId": workspace_id, "parentId": parent_id, "title": title, "accessScope": "private", "content": [], }); let body_string = serde_json::to_string(&body).map_err(|err| format!("序列化创建子页面请求失败:{err}"))?; let request_init = RequestInit::new(); request_init.set_method("POST"); request_init.set_mode(RequestMode::SameOrigin); request_init.set_body(&JsValue::from_str(&body_string)); let headers = js_sys::Object::new(); js_sys::Reflect::set( &headers, &JsValue::from_str("content-type"), &JsValue::from_str("application/json"), ) .map_err(|_| "设置创建子页面请求头失败".to_string())?; js_sys::Reflect::set( request_init.as_ref(), &JsValue::from_str("headers"), &headers, ) .map_err(|_| "设置创建子页面请求头失败".to_string())?; let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?; let response_value = JsFuture::from(win.fetch_with_str_and_init("/api/tree/commands", &request_init)) .await .map_err(|err| format!("创建子页面请求失败:{err:?}"))?; let response: Response = response_value .dyn_into() .map_err(|_| "创建子页面响应类型错误".to_string())?; let status = response.status(); let json_value = JsFuture::from( response .json() .map_err(|_| "读取创建子页面响应失败".to_string())?, ) .await .map_err(|err| format!("解析创建子页面响应失败:{err:?}"))?; let parsed: TreeCommandHttpResponse = serde_wasm_bindgen::from_value(json_value.clone()) .map_err(|err| format!("创建子页面响应 JSON 不符合预期:{err}"))?; if !response.ok() { return Err(parsed .message .unwrap_or_else(|| format!("创建子页面失败:HTTP {status}"))); } let result = parsed .result .ok_or_else(|| "创建子页面响应缺少 result".to_string())?; let document_id = result .document_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "创建子页面响应缺少 documentId".to_string())?; if document_id == parent_id { return Err("创建子页面返回了当前页面 id".to_string()); } if let Some(created_parent_id) = result .parent_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { if created_parent_id != parent_id { return Err(format!( "创建子页面父节点不符合预期:期望 {parent_id},实际 {created_parent_id}" )); } } dispatch_tree_local_command( &body, &serde_wasm_bindgen::from_value(json_value).unwrap_or(Value::Null)["result"], ); Ok(result) } fn block_plain_text_from_index( editor: TiptapEditorHandle, block_index: usize, ) -> Result { let document = editor .get_json() .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; let block = document .get("content") .and_then(Value::as_array) .and_then(|content| content.get(block_index)) .ok_or_else(|| format!("找不到第 {} 个块", block_index + 1))?; Ok(normalized_page_block_title(&collect_plain_text(block))) } fn inline_has_page_block_link(node: &Value) -> bool { if node .get("marks") .and_then(Value::as_array) .map(|marks| { marks.iter().any(|mark| { mark.get("type").and_then(Value::as_str) == Some("link") && mark .get("attrs") .and_then(|attrs| attrs.get("class")) .and_then(Value::as_str) .map(|class_name| { class_name .split_whitespace() .any(|part| part == "mnote-page-block-link") }) .unwrap_or(false) }) }) .unwrap_or(false) { return true; } node.get("content") .and_then(Value::as_array) .map(|children| children.iter().any(inline_has_page_block_link)) .unwrap_or(false) } fn top_level_block_is_page(editor: TiptapEditorHandle, block_index: usize) -> bool { editor .get_json() .ok() .and_then(|document| { document .get("content") .and_then(Value::as_array) .and_then(|content| content.get(block_index).cloned()) }) .map(|block| inline_has_page_block_link(&block)) .unwrap_or(false) } fn top_level_block_matches_action( editor: TiptapEditorHandle, block_index: usize, action: SlashActionKind, ) -> bool { let Some(block) = editor.get_json().ok().and_then(|document| { document .get("content") .and_then(Value::as_array) .and_then(|content| content.get(block_index).cloned()) }) else { return false; }; let node_type = block .get("type") .and_then(Value::as_str) .unwrap_or_default(); match action { SlashActionKind::Paragraph => { node_type == "paragraph" && !inline_has_page_block_link(&block) } SlashActionKind::Heading1 | SlashActionKind::Heading2 | SlashActionKind::Heading3 | SlashActionKind::Heading4 => { let expected_level = match action { SlashActionKind::Heading1 => 1, SlashActionKind::Heading2 => 2, SlashActionKind::Heading3 => 3, SlashActionKind::Heading4 => 4, _ => 0, }; node_type == "heading" && block .get("attrs") .and_then(|attrs| attrs.get("level")) .and_then(Value::as_i64) .map(|level| level == expected_level) .unwrap_or(false) } SlashActionKind::BulletList => node_type == "bulletList", SlashActionKind::OrderedList => node_type == "orderedList", SlashActionKind::Todo | SlashActionKind::AdvancedTodo => node_type == "taskList", SlashActionKind::Quote => node_type == "blockquote", SlashActionKind::CodeBlock => node_type == "codeBlock", SlashActionKind::Divider => node_type == "horizontalRule", SlashActionKind::SimpleTable => node_type == "table", SlashActionKind::Mindmap => { node_type == "paragraph" && block .get("attrs") .and_then(|attrs| attrs.get("mnoteBlockType")) .and_then(Value::as_str) == Some("mindmap") } SlashActionKind::Image => node_type == "image", SlashActionKind::UploadAttachment => false, SlashActionKind::Toc => node_type == "tocNode", SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi => false, } } fn page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, title: &str) -> Value { let href = if page_id.trim().is_empty() { "#".to_string() } else if let Some(workspace_id) = workspace_id .map(str::trim) .filter(|value| !value.is_empty()) { format!("/documents/{page_id}?workspaceId={workspace_id}") } else { format!("/documents/{page_id}") }; paragraph_node(vec![json!({ "type": "text", "text": normalized_page_block_title(title), "marks": [{ "type": "link", "attrs": { "href": href, "target": "_self", "rel": "noopener noreferrer nofollow", "class": "mnote-page-block-link", } }] })]) } fn replace_block_with_page_reference( editor: TiptapEditorHandle, block_index: usize, page_id: &str, workspace_id: Option<&str>, title: &str, ) -> Result<(), String> { let mut document = editor .get_json() .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; let content = document_content_mut(&mut document)?; if block_index >= content.len() { return Err(format!("找不到第 {} 个块", block_index + 1)); } content[block_index] = page_reference_paragraph_node(page_id, workspace_id, title); let next_payload = document .get("content") .and_then(Value::as_array) .cloned() .map(Value::Array) .unwrap_or(document); editor .set_content(TiptapContent::json(next_payload)) .map_err(|err| format!("转换为页面失败:{err}"))?; focus_top_level_block_start(editor, block_index)?; Ok(()) } fn run_block_turn_into_page_action( editor: TiptapEditorHandle, block_index: usize, document_id: ReadSignal>, workspace_id: ReadSignal>, title: ReadSignal, set_dirty_count: WriteSignal, set_html_output: WriteSignal, set_document_json: WriteSignal, set_json_output: WriteSignal, set_command_feedback: WriteSignal, set_block_menu_open: WriteSignal, set_block_menu_anchor: WriteSignal>, set_block_turn_into_open: WriteSignal, ) { let page_title = match block_plain_text_from_index(editor, block_index) { Ok(value) => value, Err(err) => { set_command_feedback.set(err); return; } }; let Some(parent_document_id) = document_id.get_untracked() else { set_command_feedback.set("转换为页面失败:当前页面缺少 documentId".to_string()); return; }; let workspace_id_value = workspace_id.get_untracked(); set_command_feedback.set(format!("正在创建子页面:{page_title}")); spawn_local(async move { match create_child_page_via_tree_command( parent_document_id.clone(), workspace_id_value.clone(), page_title.clone(), ) .await { Ok(created) => { let Some(child_page_id) = created.document_id.as_deref() else { set_command_feedback.set("转换为页面失败:创建响应缺少子页面 id".to_string()); return; }; let effective_workspace_id = created .workspace_id .as_deref() .or(workspace_id_value.as_deref()); let effective_title = created.title.as_deref().unwrap_or(&page_title); match replace_block_with_page_reference( editor, block_index, child_page_id, effective_workspace_id, effective_title, ) { Ok(()) => { set_block_menu_open.set(false); set_block_menu_anchor.set(None); set_block_turn_into_open.set(false); 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, format!("已转换为子页面:{effective_title}"), ); } Err(err) => set_command_feedback.set(err), } } Err(err) => set_command_feedback.set(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_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() .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_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; 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, &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_json_output = json_output; 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 |_| { let toolbar_locked = toolbar_overlay_locked( turn_into_open.get_untracked(), color_menu_open.get_untracked(), more_menu_open.get_untracked(), ); if ((!editor_focused.get() || !text_selection_active.get()) && !toolbar_locked) || 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.get_html() { Ok(current_html) => match reorder_top_level_block_html( ¤t_html, source_index, indicator.index, indicator.placement, ) { Ok(next_html) => { apply_html_update( editor, &persisted_identity, next_html, document_id, workspace_id, dirty_count, set_dirty_count, set_html_output, set_document_json, set_json_output, title, hovered_block, editor_focused, slash_open, turn_into_open, color_menu_open, more_menu_open, revision, conflict_detection_key, read_only, set_command_feedback, "已通过块手柄拖拽重排", ); } Err(err) => { set_command_feedback.set(err); } }, Err(err) => { let _ = set_command_feedback.try_set(format!("读取当前 HTML 失败:{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()), } } }; 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() } }}
match reorder_top_level_block_html( ¤t_html, source_index, indicator.index, indicator.placement, ) { Ok(next_html) => { apply_html_update( editor, &runtime_persisted_identity(document_id, workspace_id), next_html, document_id, workspace_id, dirty_count, set_dirty_count, set_html_output, set_document_json, set_json_output, title, hovered_block, editor_focused, slash_open, turn_into_open, color_menu_open, more_menu_open, revision, conflict_detection_key, read_only, set_command_feedback, "已通过块手柄拖拽重排", ); } Err(err) => { set_command_feedback.set(err); } }, Err(err) => { set_command_feedback.set(format!("读取当前 HTML 失败:{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); } > {move || { if !table_toolbar_open.get() || slash_open.get() || block_menu_open.get() || dragging_block_index.get().is_some() { return ().into_any(); } let Some(anchor) = table_overlay_anchor.get() else { return ().into_any(); }; let controls_style = format!( "left:{:.1}px;top:{:.1}px;width:{:.1}px;height:{:.1}px;", anchor.left, anchor.top, anchor.width, anchor.height ); view! {
{move || { table_selection_overlay.get().map(|selection| { view! {
} }).into_any() }} {(0..anchor.rows).map(|index| { let active = table_selection_overlay.get() .map(|selection| selection.kind == TableSelectionKind::Row && selection.index == index) .unwrap_or(false); view! { } }).collect_view()} {(0..anchor.cols).map(|index| { let active = table_selection_overlay.get() .map(|selection| selection.kind == TableSelectionKind::Column && selection.index == index) .unwrap_or(false); view! { } }).collect_view()}
}.into_any() }} {move || { if !table_toolbar_open.get() || slash_open.get() || block_menu_open.get() || dragging_block_index.get().is_some() { return ().into_any(); } view! {
{TABLE_TOOLBAR_ACTIONS.iter().enumerate().map(|(index, action)| { let action = *action; let testid = format!("table-toolbar-{}", action.id()); view! { <> {if index == 3 { view! { }.into_any() } else { ().into_any() }} } }).collect_view()} {move || { if !table_options_open.get() { return ().into_any(); } view! {
{TABLE_OPTION_ACTIONS.iter().map(|action| { let action = *action; let testid = format!("table-option-{}", action.id()); view! { } }).collect_view()}
}.into_any() }}
}.into_any() }} {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 ((!editor_focused.get() || !text_selection_active.get()) && !toolbar_locked) || 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() } }} {move || { if slash_open.get() { view! {
{move || { let query = slash_query.get().trim().to_ascii_lowercase(); let show_turn_into = !query.is_empty() && ("zhw".contains(query.as_str()) || "转换为".contains(query.as_str())); let show_page = !query.is_empty() && ("ym".contains(query.as_str()) || "页面".contains(query.as_str())); if show_turn_into || show_page { view! { <> {if show_turn_into { view! { <> }.into_any() } else { ().into_any() }} {if show_page { view! { <> }.into_any() } else { ().into_any() }} }.into_any() } else { ().into_any() } }} {SLASH_ACTIONS.iter().enumerate().map(|(index, action)| { let is_selected = move || slash_index.get() == index; let kind = action.kind; let label = action.label; let description = action.description; let shortcut = action.shortcut; let icon = action.icon; let category = action.category; 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 { view! { }.into_any() } else { ().into_any() }} } }).collect_view()}
} .into_any() } else { ().into_any() } }} { 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() } }}
} } fn initial_content() -> TiptapContent { TiptapContent::json(serde_json::json!({ "type": "doc", "content": [] })) } pub fn standalone_main() { console_error_panic_hook::set_once(); mount_to_body(|| { view! { } }); } // ── Phase B:Block Delta apply ────────────────────────────── /// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。 /// 返回 true 表示树发生了变更。 fn apply_block_delta_to_json(content: &mut Value, operations: &[Value]) -> bool { let Some(content_arr) = content.get_mut("content").and_then(Value::as_array_mut) else { return false; }; let mut changed = false; for op_value in operations { let Some(op) = op_value.get("op").and_then(Value::as_str) else { continue; }; match op { "replace" => { let block_id = op_value.get("block_id").and_then(Value::as_str); let text = op_value.get("text").and_then(Value::as_str).unwrap_or(""); let block_type = op_value.get("block_type").and_then(Value::as_str); if let Some(bid) = block_id { if apply_replace_block(content_arr, bid, text, block_type) { changed = true; } } } "insert_after" => { let anchor = op_value.get("anchor_block_id").and_then(Value::as_str); let block_id = op_value.get("block_id").and_then(Value::as_str); let text = op_value.get("text").and_then(Value::as_str).unwrap_or(""); if let (Some(aid), Some(bid)) = (anchor, block_id) { if apply_insert_block_after(content_arr, aid, bid, text) { changed = true; } } } "delete" => { let block_id = op_value.get("block_id").and_then(Value::as_str); if let Some(bid) = block_id { if apply_delete_block(content_arr, bid) { changed = true; } } } "move_after" => { let block_id = op_value.get("block_id").and_then(Value::as_str); let anchor = op_value.get("anchor_block_id").and_then(Value::as_str); if let (Some(bid), Some(aid)) = (block_id, anchor) { if apply_move_block_after(content_arr, bid, aid) { changed = true; } } } _ => {} } } changed } fn find_block_index(blocks: &[Value], block_id: &str) -> Option { blocks.iter().position(|b| { b.get("attrs") .and_then(|a| a.get("block_id")) .and_then(Value::as_str) == Some(block_id) }) } fn apply_replace_block( blocks: &mut Vec, block_id: &str, text: &str, block_type: Option<&str>, ) -> bool { let Some(idx) = find_block_index(blocks, block_id) else { return false; }; let block = &mut blocks[idx]; // 更新 block type if let Some(bt) = block_type { if let Some(b) = block.as_object_mut() { b.insert("type".into(), json!(bt)); } } // 更新 text content let new_content: Value = if text.is_empty() { json!([]) } else { json!([{ "type": "text", "text": text }]) }; if let Some(b) = block.as_object_mut() { b.insert("content".into(), new_content); } true } fn apply_insert_block_after( blocks: &mut Vec, anchor_block_id: &str, new_block_id: &str, text: &str, ) -> bool { let Some(idx) = find_block_index(blocks, anchor_block_id) else { return false; }; let new_block = json!({ "type": "paragraph", "attrs": { "block_id": new_block_id }, "content": if text.is_empty() { json!([]) } else { json!([{ "type": "text", "text": text }]) } }); blocks.insert(idx + 1, new_block); true } fn apply_delete_block(blocks: &mut Vec, block_id: &str) -> bool { let Some(idx) = find_block_index(blocks, block_id) else { return false; }; blocks.remove(idx); true } fn apply_move_block_after( blocks: &mut Vec, block_id: &str, anchor_block_id: &str, ) -> bool { let Some(block_idx) = find_block_index(blocks, block_id) else { return false; }; let Some(anchor_idx) = find_block_index(blocks, anchor_block_id) else { return false; }; // Can't move to itself or anchor after block if block_idx == anchor_idx || block_idx == anchor_idx + 1 { return false; } let block = blocks.remove(block_idx); // After removal, anchor may have shifted if block was before anchor let adjusted_anchor = if block_idx < anchor_idx { anchor_idx - 1 } else { anchor_idx }; blocks.insert(adjusted_anchor + 1, block); true }