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, TiptapInsertContentOptions, TiptapLinkResource, TiptapMarkName, TiptapRange, TiptapSelectionState, TiptapTextAlign, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display}; use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue}; use web_sys::{ window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement, MouseEvent, Node, Storage, WheelEvent, }; const EDITOR_STAGE_SELECTOR: &str = "#editor-stage"; const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror"; const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell"; const SPIKE_STORAGE_KEY: &str = "mnote.leptos-tiptap-spike.document"; const HANDLE_MULTILINE_HEIGHT: f64 = 40.0; 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 HANDLE_MENU_LEFT_LIMIT: f64 = -320.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 HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height"; const STANDALONE_ROOT_ID: &str = "mnote-leptos-tiptap-standalone-root"; #[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); } .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-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; } .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; } .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: absolute; top: 24px; left: 24px; z-index: 11; width: min(316px, calc(100% - 48px)); max-height: min(430px, calc(100vh - 180px)); 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; } .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; } .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%; } .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, } #[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, } const SLASH_ACTIONS: [SlashAction; 17] = [ 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", }, ]; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PersistedSpikeDocument { #[serde(default)] document_id: Option, #[serde(default)] workspace_id: Option, title: String, content: Value, #[serde(default)] html: Option, } #[derive(Clone, Debug, PartialEq, Eq)] struct PersistedDocumentIdentity { document_id: Option, workspace_id: Option, } #[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 SelectionPayload { selection: TiptapSelectionState, summary: String, editor_focused: bool, current_block_index: Option, current_block_id: Option, } #[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 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) ); } } #[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, } 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, } 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 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_mount_options() -> Option { RUNTIME_MOUNT_OPTIONS.with(|cell| cell.borrow().clone()) } 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 runtime_block_id_from_index(index: usize) -> Option { Some(format!("top-level-{index}")) } fn current_runtime_block_info() -> CurrentBlockInfo { hovered_block_from_selection() .and_then(|block| { Some(CurrentBlockInfo { index: Some(block.index), block_id: runtime_block_id_from_index(block.index), }) }) .unwrap_or(CurrentBlockInfo { index: None, block_id: None, }) } fn current_runtime_block_info_from_hovered( hovered_block: Option, ) -> CurrentBlockInfo { hovered_block .map(|block| CurrentBlockInfo { index: Some(block.index), block_id: runtime_block_id_from_index(block.index), }) .unwrap_or(CurrentBlockInfo { index: None, block_id: None, }) } 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(payload: &ReadyPayload) { dispatch_runtime_event(READY_EVENT, payload); } fn dispatch_change_event(payload: &ChangePayload) { dispatch_runtime_event(CHANGE_EVENT, payload); } fn dispatch_state_event(payload: &StatePayload) { dispatch_runtime_event(STATE_EVENT, payload); } fn dispatch_status_event(payload: &HostStatusPayload) { dispatch_runtime_event(STATUS_EVENT, payload); } fn dispatch_selection_event(payload: &SelectionPayload) { dispatch_runtime_event(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)) } #[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; })) } #[derive(Clone, Debug, PartialEq)] struct HoveredBlockState { index: usize, label: String, top: f64, height: f64, } #[derive(Clone, Debug, PartialEq)] struct FloatingToolbarAnchor { top: f64, left: f64, } #[derive(Clone, Debug, PartialEq)] struct BlockMenuLayout { max_height: f64, open_upward: bool, top: f64, left: f64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum DropPlacement { Before, After, } #[derive(Clone, Debug, PartialEq)] struct DropIndicatorState { index: usize, top: f64, placement: DropPlacement, } #[derive(Clone, Debug, PartialEq)] struct PendingDragState { index: usize, anchor: HoveredBlockState, start_x: i32, start_y: i32, } fn local_storage() -> Option { window().and_then(|win| win.local_storage().ok().flatten()) } 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 normalize_identity_value(value: Option) -> Option { value.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }) } fn persisted_document_identity( document_id: Option, workspace_id: Option, ) -> PersistedDocumentIdentity { PersistedDocumentIdentity { document_id: normalize_identity_value(document_id), workspace_id: normalize_identity_value(workspace_id), } } 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_document_storage_key(identity: &PersistedDocumentIdentity) -> String { match (&identity.workspace_id, &identity.document_id) { (Some(workspace_id), Some(document_id)) => { format!("{SPIKE_STORAGE_KEY}:{workspace_id}:{document_id}") } (None, Some(document_id)) => format!("{SPIKE_STORAGE_KEY}:{document_id}"), _ => SPIKE_STORAGE_KEY.to_string(), } } fn runtime_persisted_identity( document_id: ReadSignal>, workspace_id: ReadSignal>, ) -> PersistedDocumentIdentity { persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked()) } fn persist_document_state( identity: &PersistedDocumentIdentity, title: &str, content: &Value, html: Option, ) -> Result<(), String> { let payload = PersistedSpikeDocument { document_id: identity.document_id.clone(), workspace_id: identity.workspace_id.clone(), title: title.to_string(), content: content.clone(), html, }; let raw = serde_json::to_string(&payload).map_err(|err| format!("序列化草稿失败:{err}"))?; local_storage() .ok_or_else(|| "浏览器 localStorage 不可用".to_string())? .set_item(&persisted_document_storage_key(identity), &raw) .map_err(|err| format!("写入 localStorage 失败:{err:?}")) } fn load_persisted_document(identity: &PersistedDocumentIdentity) -> Option { let raw = local_storage()? .get_item(&persisted_document_storage_key(identity)) .ok() .flatten()?; let document = serde_json::from_str::(&raw).ok()?; let stored_identity = persisted_document_identity(document.document_id.clone(), document.workspace_id.clone()); if stored_identity == *identity || identity.document_id.is_none() { Some(document) } else { None } } 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") } fn selection_event_payload( selection: &TiptapSelectionState, editor_focused: bool, hovered_block: Option, ) -> SelectionPayload { SelectionPayload { selection: selection.clone(), summary: selection_summary(selection), editor_focused, current_block_index: hovered_block.as_ref().map(|block| block.index), current_block_id: hovered_block.and_then(|block| runtime_block_id_from_index(block.index)), } } fn runtime_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 { StatePayload { document_id, workspace_id, title, dirty_count, selected_block_index: hovered_block.map(|block| block.index), editor_focused, slash_open, toolbar_open: toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open), read_only, } } fn runtime_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); HostStatusPayload { document_id, workspace_id, title, dirty_count, selected_block_index: current_block_index, current_block_id: current_block_index.and_then(runtime_block_id_from_index), editor_focused, read_only, slash_open, toolbar_open: toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open), } } fn dispatch_bridge_event(event_name: &'static str, payload: &T) where T: Serialize, { dispatch_runtime_event(event_name, payload); } fn read_editor_snapshot(editor: TiptapEditorHandle) -> (String, Value, String) { let html = editor .get_html() .unwrap_or_else(|err| format!("读取 HTML 失败:{err}")); let json_value = editor.get_json().unwrap_or_else(|err| { json!({ "type": "error", "message": format!("读取 JSON 失败:{err}"), }) }); let json_text = serde_json::to_string_pretty(&json_value) .unwrap_or_else(|err| format!("格式化 JSON 失败:{err}")); (html, json_value, json_text) } fn sync_editor_outputs( editor: TiptapEditorHandle, set_html_output: WriteSignal, set_document_json: WriteSignal, set_json_output: WriteSignal, ) -> Value { let (html, json_value, json_text) = read_editor_snapshot(editor); set_html_output.set(html); set_document_json.set(json_value.clone()); set_json_output.set(json_text); json_value } fn editor_root_element() -> Option { window() .and_then(|win| win.document()) .and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten()) } 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 direct_block_from_element(mut element: Element, root: &Element) -> Option { loop { let parent = element.parent_element()?; if parent.is_same_node(Some(root)) { return Some(element); } element = parent; } } fn top_level_block_index(root: &Element, block: &Element) -> Option { let children = root.children(); (0..children.length()).find_map(|index| { children .item(index) .filter(|child| child.is_same_node(Some(block))) .map(|_| index as usize) }) } 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 target_element(target: web_sys::EventTarget) -> Option { target.clone().dyn_into::().ok().or_else(|| { target .dyn_into::() .ok() .and_then(|node| node.parent_element()) }) } fn event_target_matches_selector(target: Option, selector: &str) -> bool { target .and_then(target_element) .and_then(|element| element.closest(selector).ok().flatten()) .is_some() } 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)?; if element .closest(HANDLE_SHELL_SELECTOR) .ok() .flatten() .is_some() { return None; } let block = direct_block_from_element(element, &root)?; let index = top_level_block_index(&root, &block)?; 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 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(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 node_is_within_root(node: Node, root: &Element) -> bool { let mut current = Some(node); while let Some(node) = current { if node.is_same_node(Some(root)) { return true; } current = node.parent_node(); } false } fn active_editor_text_selection() -> Option { let selection = window().and_then(|win| win.get_selection().ok().flatten())?; if selection.is_collapsed() { return None; } let root = editor_root_element()?; let anchor_inside = selection .anchor_node() .map(|node| node_is_within_root(node, &root)) .unwrap_or(false); let focus_inside = selection .focus_node() .map(|node| node_is_within_root(node, &root)) .unwrap_or(false); if anchor_inside || focus_inside { Some(selection) } else { None } } fn floating_toolbar_anchor_from_selection() -> Option { let selection = active_editor_text_selection()?; let range = selection.get_range_at(0).ok()?; let rect = range.get_bounding_client_rect(); if rect.width() <= 0.0 && rect.height() <= 0.0 { return None; } let stage = editor_stage_element()?; let stage_rect = stage.get_bounding_client_rect(); Some(FloatingToolbarAnchor { top: (rect.top() - stage_rect.top()).max(60.0), left: rect.left() + (rect.width() / 2.0) - stage_rect.left(), }) } fn hover_anchor_top(block: &HoveredBlockState) -> f64 { if block.height > HANDLE_MULTILINE_HEIGHT { block.top + 12.0 } else { block.top + (block.height / 2.0) - 32.0 } } fn hover_anchor_transform(_block: &HoveredBlockState) -> &'static str { "none" } 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 x = f64::from(client_x) - stage_rect.left(); let y = f64::from(client_y) - 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); let corridor_left = (handle_left - 12.0).min(block_left); let corridor_right = (block_left + 18.0).max(handle_right + 12.0); x >= corridor_left && x <= corridor_right && y >= block_top - 18.0 && y <= block_bottom + 18.0 } fn block_menu_layout(block: &HoveredBlockState) -> Option { let stage = editor_stage_element()?; let stage_rect = stage.get_bounding_client_rect(); let shell_top = hover_anchor_top(block); let stage_padding = 18.0; let space_above = (shell_top - stage_padding).max(0.0); let space_below = (stage_rect.height() - shell_top - stage_padding).max(0.0); let open_upward = space_below < 340.0 && space_above > space_below; let available_height = if open_upward { space_above } else { space_below }; let max_height = if available_height >= 220.0 { available_height } else { available_height.max(160.0) }; let menu_width = 242.0; let menu_stage_left = (content_text_left(stage_rect.width()) - menu_width - 132.0).max(HANDLE_MENU_LEFT_LIMIT); let viewport_shell_top = stage_rect.top() + shell_top; let top = if open_upward { viewport_shell_top + 64.0 - max_height } else { viewport_shell_top } .max(8.0); let left = (stage_rect.left() + menu_stage_left).max(8.0); Some(BlockMenuLayout { max_height, open_upward, top, left, }) } 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()?; let block_rect = block.get_bounding_client_rect(); let stage_rect = stage.get_bounding_client_rect(); let midpoint = block_rect.top() + (block_rect.height() / 2.0); let placement = if f64::from(client_y) <= midpoint { DropPlacement::Before } else { DropPlacement::After }; let top = match placement { DropPlacement::Before => block_rect.top() - stage_rect.top(), DropPlacement::After => block_rect.bottom() - stage_rect.top(), }; Some(DropIndicatorState { index: hovered.index, top, placement, }) } fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option { let target = window() .and_then(|win| win.document()) .and_then(|document| document.element_from_point(client_x as f32, client_y as f32))?; let target: web_sys::EventTarget = target.into(); 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 node_with_attrs( type_name: &str, attrs: Option>, content: Vec, ) -> Value { let mut node = Map::new(); node.insert("type".to_string(), Value::String(type_name.to_string())); if let Some(attrs) = attrs.filter(|attrs| !attrs.is_empty()) { node.insert("attrs".to_string(), Value::Object(attrs)); } if !content.is_empty() { node.insert("content".to_string(), Value::Array(content)); } Value::Object(node) } fn paragraph_node(content: Vec) -> Value { node_with_attrs("paragraph", None, content) } fn list_item_node(content: Vec) -> Value { node_with_attrs("listItem", None, vec![paragraph_node(content)]) } fn task_item_node(content: Vec) -> Value { let mut attrs = Map::new(); attrs.insert("checked".to_string(), Value::Bool(false)); node_with_attrs("taskItem", Some(attrs), vec![paragraph_node(content)]) } fn collect_plain_text(node: &Value) -> String { match node.get("type").and_then(Value::as_str) { Some("text") => node .get("text") .and_then(Value::as_str) .unwrap_or_default() .to_string(), Some("hardBreak") => "\n".to_string(), _ => node .get("content") .and_then(Value::as_array) .map(|children| { children .iter() .map(collect_plain_text) .collect::>() .join("") }) .unwrap_or_default(), } } fn inline_from_first_child(children: &[Value]) -> Vec { children .first() .map(extract_inline_content) .unwrap_or_default() } fn extract_inline_content(node: &Value) -> Vec { match node.get("type").and_then(Value::as_str) { Some("paragraph") | Some("heading") => node .get("content") .and_then(Value::as_array) .cloned() .unwrap_or_default(), Some("blockquote") => node .get("content") .and_then(Value::as_array) .map(|children| inline_from_first_child(children)) .unwrap_or_default(), Some("bulletList") | Some("orderedList") | Some("taskList") => node .get("content") .and_then(Value::as_array) .and_then(|items| items.first()) .and_then(|item| item.get("content")) .and_then(Value::as_array) .map(|children| inline_from_first_child(children)) .unwrap_or_default(), Some("codeBlock") => { let text = collect_plain_text(node); if text.is_empty() { Vec::new() } else { vec![json!({"type": "text", "text": text})] } } Some("horizontalRule") => Vec::new(), _ => node .get("content") .and_then(Value::as_array) .map(|children| { children .iter() .flat_map(extract_inline_content) .collect::>() }) .unwrap_or_default(), } } fn turn_into_block(node: &Value, action: SlashActionKind) -> Value { let inline = extract_inline_content(node); match action { SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi => paragraph_node(inline.clone()), SlashActionKind::Paragraph => paragraph_node(inline), SlashActionKind::Heading1 => { let mut attrs = Map::new(); attrs.insert("level".to_string(), json!(1)); node_with_attrs("heading", Some(attrs), inline) } SlashActionKind::Heading2 => { let mut attrs = Map::new(); attrs.insert("level".to_string(), json!(2)); node_with_attrs("heading", Some(attrs), inline) } SlashActionKind::Heading3 => { let mut attrs = Map::new(); attrs.insert("level".to_string(), json!(3)); node_with_attrs("heading", Some(attrs), inline) } SlashActionKind::Heading4 => { let mut attrs = Map::new(); attrs.insert("level".to_string(), json!(4)); node_with_attrs("heading", Some(attrs), inline) } SlashActionKind::BulletList => { node_with_attrs("bulletList", None, vec![list_item_node(inline)]) } SlashActionKind::OrderedList => { node_with_attrs("orderedList", None, vec![list_item_node(inline)]) } SlashActionKind::Todo | SlashActionKind::AdvancedTodo => { node_with_attrs("taskList", None, vec![task_item_node(inline)]) } SlashActionKind::Quote => node_with_attrs("blockquote", None, vec![paragraph_node(inline)]), SlashActionKind::CodeBlock => { let text = collect_plain_text(node); let mut attrs = Map::new(); attrs.insert("language".to_string(), Value::String("rust".to_string())); let content = if text.is_empty() { Vec::new() } else { vec![json!({"type": "text", "text": text})] }; node_with_attrs("codeBlock", Some(attrs), content) } SlashActionKind::Divider => node_with_attrs("horizontalRule", None, Vec::new()), } } fn duplicate_top_level_block(document: &mut Value, index: usize) -> Result<(), String> { let content = document_content_mut(document)?; let block = content .get(index) .cloned() .ok_or_else(|| format!("找不到第 {index} 个块"))?; content.insert(index + 1, block); Ok(()) } fn delete_top_level_block(document: &mut Value, index: usize) -> Result<(), String> { let content = document_content_mut(document)?; if index >= content.len() { return Err(format!("找不到第 {index} 个块")); } if content.len() == 1 { content[0] = paragraph_node(Vec::new()); return Ok(()); } content.remove(index); Ok(()) } fn reorder_top_level_block( document: &mut Value, source: usize, target: usize, placement: DropPlacement, ) -> Result<(), String> { let content = document_content_mut(document)?; if source >= content.len() || target >= content.len() { return Err("拖拽目标超出当前顶层块范围".to_string()); } let block = content.remove(source); let mut insert_at = match placement { DropPlacement::Before => target, DropPlacement::After => target + 1, }; if source < insert_at { insert_at = insert_at.saturating_sub(1); } if insert_at > content.len() { insert_at = content.len(); } content.insert(insert_at, block); Ok(()) } fn replace_top_level_block_kind( document: &mut Value, index: usize, action: SlashActionKind, ) -> Result<(), String> { let content = document_content_mut(document)?; let next = turn_into_block( content .get(index) .ok_or_else(|| format!("找不到第 {index} 个块"))?, action, ); content[index] = next; Ok(()) } fn has_active_text_selection() -> bool { active_editor_text_selection().is_some() } fn toolbar_overlay_locked( turn_into_open: bool, color_menu_open: bool, more_menu_open: bool, ) -> bool { turn_into_open || color_menu_open || more_menu_open } fn sync_text_selection_overlay( set_text_selection_active: WriteSignal, set_floating_toolbar_anchor: WriteSignal>, ) -> bool { let toolbar_anchor = floating_toolbar_anchor_from_selection(); let has_text_selection = toolbar_anchor.is_some(); set_text_selection_active.set(has_text_selection); set_floating_toolbar_anchor.set(toolbar_anchor); has_text_selection } fn sync_editor_overlay_state( set_editor_focused: WriteSignal, set_text_selection_active: WriteSignal, set_floating_toolbar_anchor: WriteSignal>, set_slash_open: WriteSignal, set_turn_into_open: WriteSignal, set_hovered_block: WriteSignal>, set_block_menu_anchor: WriteSignal>, set_block_menu_open: WriteSignal, ) { let focused = active_editor_stage(); set_editor_focused.set(focused); let has_text_selection = sync_text_selection_overlay(set_text_selection_active, set_floating_toolbar_anchor); if focused && has_text_selection { set_slash_open.set(false); set_turn_into_open.set(false); set_block_menu_open.set(false); set_block_menu_anchor.set(None); set_hovered_block.set(None); } } fn try_sync_editor_overlay_state( set_editor_focused: WriteSignal, set_text_selection_active: WriteSignal, set_floating_toolbar_anchor: WriteSignal>, set_slash_open: WriteSignal, set_turn_into_open: WriteSignal, set_hovered_block: WriteSignal>, set_block_menu_anchor: WriteSignal>, set_block_menu_open: WriteSignal, ) -> bool { let focused = active_editor_stage(); if set_editor_focused.try_set(focused).is_none() { return false; } let toolbar_anchor = floating_toolbar_anchor_from_selection(); let has_text_selection = toolbar_anchor.is_some(); if set_text_selection_active .try_set(has_text_selection) .is_none() { return false; } if set_floating_toolbar_anchor .try_set(toolbar_anchor) .is_none() { return false; } if focused && has_text_selection { if set_slash_open.try_set(false).is_none() { return false; } if set_turn_into_open.try_set(false).is_none() { return false; } if set_block_menu_open.try_set(false).is_none() { return false; } if set_block_menu_anchor.try_set(None).is_none() { return false; } if set_hovered_block.try_set(None).is_none() { return false; } } true } fn apply_document_update( editor: TiptapEditorHandle, persisted_identity: &PersistedDocumentIdentity, next_document: Value, set_dirty_count: WriteSignal, set_html_output: WriteSignal, set_document_json: WriteSignal, set_json_output: WriteSignal, title: ReadSignal, set_command_feedback: WriteSignal, success_message: impl Into, ) { let success_message = success_message.into(); let next_payload = next_document .get("content") .and_then(Value::as_array) .cloned() .map(Value::Array) .unwrap_or_else(|| next_document.clone()); match editor.set_content(TiptapContent::json(next_payload)) { 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); 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 apply_html_update( editor: TiptapEditorHandle, persisted_identity: &PersistedDocumentIdentity, next_html: String, set_dirty_count: WriteSignal, set_html_output: WriteSignal, set_document_json: WriteSignal, set_json_output: WriteSignal, title: 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); 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 duplicate_top_level_block_html(current_html: &str, index: usize) -> Result { let document = window() .and_then(|win| win.document()) .ok_or_else(|| "浏览器 document 不可用".to_string())?; let container = document .create_element("div") .map_err(|err| format!("创建 HTML 容器失败:{err:?}"))?; container.set_inner_html(current_html); let children = container.children(); let current = children .item(index as u32) .ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?; let cloned = current .clone_node_with_deep(true) .map_err(|err| format!("复制 HTML 块失败:{err:?}"))? .dyn_into::() .map_err(|_| "复制出的节点不是 Element".to_string())?; if let Some(next_sibling) = children.item(index as u32 + 1) { container .insert_before(&cloned, Some(&next_sibling)) .map_err(|err| format!("插入复制块失败:{err:?}"))?; } else { container .append_child(&cloned) .map_err(|err| format!("追加复制块失败:{err:?}"))?; } Ok(container.inner_html()) } 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 sync_persisted_editor_command( editor: TiptapEditorHandle, persisted_identity: &PersistedDocumentIdentity, set_dirty_count: WriteSignal, set_html_output: WriteSignal, set_document_json: WriteSignal, set_json_output: WriteSignal, title: ReadSignal, set_command_feedback: WriteSignal, success_message: impl Into, ) { let success_message = success_message.into(); 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); 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}")), } } 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 delete_top_level_block_html(current_html: &str, index: usize) -> Result { let document = window() .and_then(|win| win.document()) .ok_or_else(|| "浏览器 document 不可用".to_string())?; let container = document .create_element("div") .map_err(|err| format!("创建 HTML 容器失败:{err:?}"))?; container.set_inner_html(current_html); let children = container.children(); if children.length() <= 1 { container.set_inner_html("

"); return Ok(container.inner_html()); } let current = children .item(index as u32) .ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?; container .remove_child(¤t) .map_err(|err| format!("删除 HTML 块失败:{err:?}"))?; Ok(container.inner_html()) } fn reorder_top_level_block_html( current_html: &str, source: usize, target: usize, placement: DropPlacement, ) -> Result { let document = window() .and_then(|win| win.document()) .ok_or_else(|| "浏览器 document 不可用".to_string())?; let container = document .create_element("div") .map_err(|err| format!("创建 HTML 容器失败:{err:?}"))?; container.set_inner_html(current_html); let initial_children = container.children(); let block_count = initial_children.length() as usize; if source >= block_count || target >= block_count { return Err("拖拽目标超出当前顶层块范围".to_string()); } let current = initial_children .item(source as u32) .ok_or_else(|| format!("找不到第 {source} 个 HTML 顶层块"))?; let moving = current .clone_node_with_deep(true) .map_err(|err| format!("复制拖拽块失败:{err:?}"))? .dyn_into::() .map_err(|_| "复制出的拖拽块不是 Element".to_string())?; container .remove_child(¤t) .map_err(|err| format!("移除原始拖拽块失败:{err:?}"))?; let mut insert_at = match placement { DropPlacement::Before => target, DropPlacement::After => target + 1, }; if source < insert_at { insert_at = insert_at.saturating_sub(1); } let current_children = container.children(); let current_len = current_children.length() as usize; if insert_at >= current_len { container .append_child(&moving) .map_err(|err| format!("追加拖拽块失败:{err:?}"))?; } else { let next_sibling = current_children .item(insert_at as u32) .ok_or_else(|| format!("找不到第 {insert_at} 个 HTML 插入位置"))?; container .insert_before(&moving, Some(&next_sibling)) .map_err(|err| format!("插入拖拽块失败:{err:?}"))?; } Ok(container.inner_html()) } 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::History, TiptapExtension::Underline, TiptapExtension::TextStyle, TiptapExtension::TextAlign, TiptapExtension::Highlight, TiptapExtension::Link, TiptapExtension::Placeholder, ] } fn active_editor_stage() -> bool { let active_element_inside_stage = window() .and_then(|win| win.document()) .and_then(|document| document.active_element()) .and_then(|element| element.closest(EDITOR_STAGE_SELECTOR).ok().flatten()) .is_some(); if active_element_inside_stage { return true; } let Some(root) = editor_root_element() else { return false; }; let Some(selection) = window().and_then(|win| win.get_selection().ok().flatten()) else { return false; }; let anchor_inside = selection .anchor_node() .map(|node| node_is_within_root(node, &root)) .unwrap_or(false); let focus_inside = selection .focus_node() .map(|node| node_is_within_root(node, &root)) .unwrap_or(false); anchor_inside || focus_inside } fn selection_summary(selection: &TiptapSelectionState) -> String { let block = if selection.h1 { "一级标题" } else if selection.h2 { "二级标题" } else if selection.h3 { "三级标题" } else if selection.task_list { "Todo 列表" } else if selection.ordered_list { "有序列表" } else if selection.bullet_list { "无序列表" } else if selection.blockquote { "引用块" } else if selection.paragraph { "段落" } else { "未定位" }; let mut marks = Vec::new(); if selection.bold { marks.push("Bold"); } if selection.italic { marks.push("Italic"); } if selection.underline { marks.push("Underline"); } if selection.strike { marks.push("Strike"); } if selection.highlight { marks.push("Highlight"); } if selection.text_style { marks.push("Color"); } if selection.link { marks.push("Link"); } let mark_text = if marks.is_empty() { "无行内样式".to_string() } else { marks.join(" / ") }; format!("当前块:{block} | 行内样式:{mark_text}") } 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 selection_payload( selection: &TiptapSelectionState, editor_focused: bool, current_block_index: Option, ) -> SelectionPayload { SelectionPayload { selection: selection.clone(), summary: selection_summary(selection), editor_focused, current_block_index, current_block_id: current_block_index.and_then(runtime_block_id_from_index), } } 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 send_selection_state( selection: &TiptapSelectionState, editor_focused: bool, current_block_index: Option, ) { dispatch_selection_event(&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 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()); } Err(err) => { set_command_feedback.set(format!("宿主文档同步失败:{err}")); } } } } fn bridge_ready_payload() -> ReadyPayload { ReadyPayload { runtime_name: RUNTIME_NAME, selectors: BridgeSelectorsPayload { root: "[data-testid=\"mnote-leptos-tiptap-host\"]", stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]", editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]", toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]", slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]", handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]", }, supported_commands: vec![ "replaceContent", "setPageOptions", "setEditable", "undo", "redo", "focus", "requestCurrentBlockId", ], supports_embedded_mode: true, } } fn selection_has_rich_marks(selection: &TiptapSelectionState) -> bool { selection.bold || selection.italic || selection.underline || selection.strike || selection.highlight || selection.text_style || selection.link } 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 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(), }; 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 => "已插入分割线", }) .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::Divider => editor .insert_content_at( range, TiptapContent::json(json!({ "type": "horizontalRule" })), 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 normalized_page_block_title(text: &str) -> String { let trimmed = text.trim(); if trimmed.is_empty() { "未命名页面".to_string() } else { trimmed.to_string() } } 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 page_reference_paragraph_node(page_id: &str, title: &str) -> Value { let href = if page_id.trim().is_empty() { "#".to_string() } else { format!("/documents/{page_id}") }; paragraph_node(vec![json!({ "type": "text", "text": normalized_page_block_title(title), "marks": [{ "type": "link", "attrs": { "href": href, "target": null, "rel": "noopener noreferrer nofollow", "class": "mnote-page-block-link", } }] })]) } fn replace_block_with_page_reference( editor: TiptapEditorHandle, block_index: usize, page_id: &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, 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 page_id = document_id .get_untracked() .unwrap_or_else(|| format!("page-block-{}", block_index + 1)); match replace_block_with_page_reference(editor, block_index, &page_id, &page_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!("已转换为页面:{page_title}"), ); } 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 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 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 = resolve_persisted_document_identity( initial_document_id.clone(), initial_workspace_id.clone(), ); let persisted = load_persisted_document(&persisted_identity); let restored_from_storage = persisted.is_some(); let restored_html = persisted .as_ref() .and_then(|document| document.html.clone()); let initial_title_value = mount_options .title .clone() .or_else(|| persisted.as_ref().map(|document| document.title.clone())) .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(|| { persisted.as_ref().map(|document| { document .html .clone() .map(TiptapContent::html) .unwrap_or_else(|| TiptapContent::json(document.content.clone())) }) }) .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( persisted .as_ref() .map(|document| document.content.clone()) .unwrap_or_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 (floating_toolbar_anchor, set_floating_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 (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 (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 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; Effect::new(move |_| { if !block_menu_open.get() { set_block_turn_into_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_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); 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(), ); } 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), ); dispatch_status_event(&HostStatusPayload { document_id: document_id.get_untracked(), workspace_id: workspace_id.get_untracked(), title: title.get_untracked(), dirty_count: dirty_count.get_untracked(), selected_block_index: current_block.index, current_block_id: current_block.block_id, editor_focused: editor_focused.get_untracked(), read_only: read_only.get_untracked(), slash_open: slash_open.get_untracked(), toolbar_open: toolbar_overlay_locked( turn_into_open.get_untracked(), color_menu_open.get_untracked(), more_menu_open.get_untracked(), ), }); } 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); } } } }); { 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_floating_toolbar_anchor = set_floating_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, set_dirty_count, set_html_output, set_document_json, set_json_output, title, 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")) .ok() .flatten() .is_some() { return; } } } 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 set_turn_into_open = set_turn_into_open; 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_command_feedback = set_command_feedback; let keydown_handle = window_event_listener(ev::keydown, move |event| { let focused = active_editor_stage(); if !focused { return; } if event.key() == "Escape" { event.prevent_default(); let overlay_open = slash_open.try_get_untracked().unwrap_or(false) || block_menu_open.try_get_untracked().unwrap_or(false); 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); if overlay_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(); let _ = set_slash_open.try_set(true); 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_block_keyboard_mode.try_set(false); let _ = set_slash_index.try_set(0); 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); } "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); } _ => {} } }); on_cleanup(move || drop(keydown_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, set_dirty_count, set_html_output, set_document_json, set_json_output, title, 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 || { 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 || { 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", hover_anchor_top(&block)); let anchor_transform = 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 = block_menu_layout(&block).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::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi)) .map(|action| { let kind = action.kind; let label = action.label; let icon = action.icon; let shortcut = 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+-", _ => "", }; let testid = format!("block-transform-item-{}", action.id); let action_menu_label = "块".to_string(); view! { } }) .collect_view()}
}.into_any() } else { ().into_any() } }} } }}
}.into_any() } else { ().into_any() } }}
}.into_any() } else { ().into_any() } }} {move || { if slash_open.get() { view! {
{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); 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); 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( &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}")); } } } on_selection_change=move |selection: TiptapSelectionState| { let selection_clone = selection.clone(); set_selection_state.set(selection_clone.clone()); let toolbar_locked = toolbar_overlay_locked( turn_into_open.get_untracked(), color_menu_open.get_untracked(), more_menu_open.get_untracked(), ); let has_text_selection = if has_active_text_selection() { sync_text_selection_overlay( set_text_selection_active, set_floating_toolbar_anchor, ) } else if toolbar_locked { text_selection_active.get_untracked() } else { set_text_selection_active.set(false); set_floating_toolbar_anchor.set(None); false }; if has_text_selection { if !toolbar_locked { set_turn_into_open.set(false); } set_block_menu_open.set(false); set_block_menu_anchor.set(None); set_hovered_block.set(None); } send_selection_state( &selection_clone, editor_focused.get_untracked(), hovered_block.get_untracked().map(|block| block.index), ); } 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! { } }); }