Files
mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs
T

5451 lines
210 KiB
Rust
Raw Normal View History

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,
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 HANDLE_GUTTER_WIDTH: f64 = 92.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 = 32.0;
const HANDLE_TRIGGER_GAP: f64 = 6.0;
const HANDLE_MENU_GAP: f64 = 10.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: 14;
display: inline-flex;
align-items: center;
gap: 6px;
}
.block-handle-shell[data-dragging="true"] {
pointer-events: none;
opacity: 0.72;
}
.block-handle-insert,
.block-handle-trigger {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 11px;
background: rgba(255, 255, 255, 0.98);
color: #334155;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease;
}
.block-handle-insert {
color: var(--accent-strong);
cursor: pointer;
}
.block-handle-insert span {
font-size: 20px;
line-height: 1;
}
.block-handle-insert:hover,
.block-handle-trigger:hover {
transform: translateY(-1px);
border-color: rgba(15, 118, 110, 0.34);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.16);
}
.block-handle-trigger:active {
cursor: grabbing;
}
.block-handle-trigger {
cursor: grab;
font-size: 18px;
letter-spacing: -0.08em;
}
.block-drag-menu {
position: absolute;
top: 0;
left: calc(100% + 10px);
min-width: 248px;
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 18px;
background: rgba(255, 255, 255, 0.98);
color: var(--ink);
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.18);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.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-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: 6px;
}
.block-drag-menu-section {
display: grid;
gap: 6px;
}
.block-drag-menu-divider {
height: 1px;
background: rgba(148, 163, 184, 0.22);
}
.block-drag-menu-item {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
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;
}
.block-drag-menu-item:hover {
background: rgba(15, 118, 110, 0.08);
border-color: rgba(15, 118, 110, 0.18);
}
.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: 18px;
background: rgba(255, 255, 255, 0.98);
color: #0f172a;
box-shadow: 0 18px 40px 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: 34px;
height: 34px;
border: none;
border-radius: 12px;
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 span,
.toolbar-menu-item span {
color: var(--muted);
font-size: 12px;
}
.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(360px, calc(100% - 48px));
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 22px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.16);
}
.slash-list {
display: grid;
gap: 6px;
}
.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 {
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 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;
}
.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;
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 {
Paragraph,
Heading1,
Heading2,
Heading3,
BulletList,
OrderedList,
Todo,
Quote,
CodeBlock,
Divider,
}
#[derive(Clone, Copy)]
struct SlashAction {
kind: SlashActionKind,
id: &'static str,
label: &'static str,
description: &'static str,
}
const SLASH_ACTIONS: [SlashAction; 10] = [
SlashAction {
kind: SlashActionKind::Paragraph,
id: "paragraph",
label: "段落",
description: "回到普通正文块",
},
SlashAction {
kind: SlashActionKind::Heading1,
id: "heading-1",
label: "一级标题",
description: "建立页面主层级",
},
SlashAction {
kind: SlashActionKind::Heading2,
id: "heading-2",
label: "二级标题",
description: "整理章节结构",
},
SlashAction {
kind: SlashActionKind::Heading3,
id: "heading-3",
label: "三级标题",
description: "补细粒度小节",
},
SlashAction {
kind: SlashActionKind::BulletList,
id: "bullet",
label: "无序列表",
description: "记录普通要点",
},
SlashAction {
kind: SlashActionKind::OrderedList,
id: "ordered",
label: "有序列表",
description: "记录步骤顺序",
},
SlashAction {
kind: SlashActionKind::Todo,
id: "todo",
label: "Todo 列表",
description: "切到真正的 taskList/taskItem",
},
SlashAction {
kind: SlashActionKind::Quote,
id: "quote",
label: "引用块",
description: "包住注释与摘录",
},
SlashAction {
kind: SlashActionKind::CodeBlock,
id: "code-block",
label: "代码块",
description: "插入带语义的代码块",
},
SlashAction {
kind: SlashActionKind::Divider,
id: "divider",
label: "分割线",
description: "插入 horizontal rule",
},
];
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistedSpikeDocument {
#[serde(default)]
document_id: Option<String>,
#[serde(default)]
workspace_id: Option<String>,
title: String,
content: Value,
#[serde(default)]
html: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PersistedDocumentIdentity {
document_id: Option<String>,
workspace_id: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BridgeEnvelope<T>
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<String>,
workspace_id: Option<String>,
title: String,
dirty_count: u32,
selected_block_index: Option<usize>,
current_block_id: Option<String>,
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<String>,
workspace_id: Option<String>,
title: String,
dirty_count: u32,
selected_block_index: Option<usize>,
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<usize>,
current_block_id: Option<String>,
}
#[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<usize>,
revision: Option<i64>,
conflict_detection_key: Option<String>,
read_only: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ChangePayload {
document_id: Option<String>,
workspace_id: Option<String>,
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<String>,
runtime: Option<String>,
version: Option<String>,
source: Option<String>,
event: Option<String>,
payload: Option<HostCommandPayload>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RuntimePageOptions {
wide_layout: Option<bool>,
small_text: Option<bool>,
layout_density: Option<String>,
show_heading_numbers: Option<bool>,
embed_default_block_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HostCommandPayload {
command: Option<String>,
document_id: Option<String>,
workspace_id: Option<String>,
title: Option<String>,
content: Option<Value>,
editable: Option<bool>,
page_options: Option<RuntimePageOptions>,
block_id: Option<String>,
block_index: Option<usize>,
text: Option<String>,
reference_document_id: Option<String>,
reference_block_id: Option<String>,
current_block_id: Option<String>,
selection: Option<Value>,
revision: Option<i64>,
conflict_detection_key: Option<String>,
read_only: Option<bool>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyHostEnvelope {
protocol: Option<String>,
runtime: Option<String>,
version: Option<String>,
source: Option<String>,
event: Option<String>,
payload: Option<HostDocumentPayload>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HostDocumentPayload {
document_id: Option<String>,
workspace_id: Option<String>,
title: Option<String>,
content: Option<Value>,
revision: Option<i64>,
conflict_detection_key: Option<String>,
read_only: Option<bool>,
}
#[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<Self> {
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<Self> {
payload
.command
.as_deref()
.and_then(Self::from_event_name)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CurrentBlockInfo {
index: Option<usize>,
block_id: Option<String>,
}
#[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<String>,
workspace_id: Option<String>,
title: Option<String>,
content: Option<Value>,
html: Option<String>,
editable: Option<bool>,
read_only: Option<bool>,
revision: Option<i64>,
conflict_detection_key: Option<String>,
page_options: Option<RuntimePageOptions>,
}
struct RuntimeMountContext {
id: u32,
target: EventTarget,
mode: RuntimeDeliveryMode,
}
#[derive(Clone, Debug)]
struct RuntimeMountOptions {
options: MountOptions,
mode: RuntimeDeliveryMode,
}
struct MountedRuntimeListener {
target: EventTarget,
listener: Closure<dyn FnMut(Event)>,
}
struct MountedRuntime {
listeners: Vec<MountedRuntimeListener>,
mount_handle: Box<dyn Any>,
}
impl Drop for MountedRuntime {
fn drop(&mut self) {
for listener in &self.listeners {
let _ = listener.target.remove_event_listener_with_callback(
COMMAND_EVENT,
listener.listener.as_ref().unchecked_ref(),
);
}
}
}
thread_local! {
static RUNTIME_MOUNT_CONTEXT: std::cell::RefCell<Option<RuntimeMountContext>> = const { std::cell::RefCell::new(None) };
static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell<Option<RuntimeMountOptions>> = const { std::cell::RefCell::new(None) };
static MOUNTED_HANDLES: std::cell::RefCell<HashMap<u32, MountedRuntime>> = std::cell::RefCell::new(HashMap::new());
static PENDING_RUNTIME_LISTENERS: std::cell::RefCell<HashMap<u32, Vec<MountedRuntimeListener>>> = std::cell::RefCell::new(HashMap::new());
static NEXT_MOUNT_ID: Cell<u32> = const { Cell::new(1) };
}
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<RuntimeMountContext>) {
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<RuntimeMountOptions>) {
RUNTIME_MOUNT_OPTIONS.with(|cell| {
*cell.borrow_mut() = options;
});
}
fn runtime_mount_options() -> Option<RuntimeMountOptions> {
RUNTIME_MOUNT_OPTIONS.with(|cell| cell.borrow().clone())
}
fn runtime_event_target() -> Option<EventTarget> {
if let Some((_, target, _)) = runtime_mount_context() {
return Some(target);
}
window()
.and_then(|win| win.document())
.and_then(|document| document.body())
.map(|body| body.into())
}
fn runtime_block_id_from_index(index: usize) -> Option<String> {
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<HoveredBlockState>) -> 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<T>(event_name: &'static str, payload: &T)
where
T: Serialize,
{
let envelope = BridgeEnvelope {
protocol: PROTOCOL,
runtime: RUNTIME_NAME,
version: RUNTIME_VERSION,
source: EVENT_PREFIX,
event: event_name,
payload,
};
let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else {
return;
};
let Some(target) = runtime_event_target() else {
return;
};
let init = CustomEventInit::new();
init.set_detail(&detail_value);
if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) {
let _ = target.dispatch_event(&event);
}
}
fn dispatch_custom_event_to_target<T>(target: &EventTarget, event_name: &'static str, payload: &T)
where
T: Serialize,
{
let envelope = BridgeEnvelope {
protocol: PROTOCOL,
runtime: RUNTIME_NAME,
version: RUNTIME_VERSION,
source: EVENT_PREFIX,
event: event_name,
payload,
};
let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else {
return;
};
let init = CustomEventInit::new();
init.set_detail(&detail_value);
init.set_bubbles(true);
if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) {
let _ = target.dispatch_event(&event);
}
}
fn dispatch_ready_event(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<M: Any + leptos::prelude::Mountable + 'static>(
id: u32,
target: EventTarget,
listener: Closure<dyn FnMut(Event)>,
handle: UnmountHandle<M>,
) {
let mut listeners = vec![MountedRuntimeListener { target, listener }];
PENDING_RUNTIME_LISTENERS.with(|registry| {
if let Some(mut pending) = registry.borrow_mut().remove(&id) {
listeners.append(&mut pending);
}
});
MOUNTED_HANDLES.with(|registry| {
registry.borrow_mut().insert(
id,
MountedRuntime {
listeners,
mount_handle: Box::new(handle),
},
);
});
}
fn register_runtime_listener(
id: u32,
target: EventTarget,
listener: Closure<dyn FnMut(Event)>,
) {
MOUNTED_HANDLES.with(|registry| {
if let Some(runtime) = registry.borrow_mut().get_mut(&id) {
runtime
.listeners
.push(MountedRuntimeListener { target, listener });
} else {
PENDING_RUNTIME_LISTENERS.with(|pending_registry| {
pending_registry
.borrow_mut()
.entry(id)
.or_default()
.push(MountedRuntimeListener { target, listener });
});
}
});
}
fn take_unmount_handle(id: u32) -> Option<MountedRuntime> {
PENDING_RUNTIME_LISTENERS.with(|registry| {
registry.borrow_mut().remove(&id);
});
MOUNTED_HANDLES.with(|registry| registry.borrow_mut().remove(&id))
}
#[wasm_bindgen]
pub fn mount(container: Element, options: JsValue) -> Result<u32, JsValue> {
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::<HtmlElement>()
.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! {
<App mount_options=options />
}
});
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<dyn FnMut(Event)> {
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
return;
};
let detail = custom_event.detail();
let Ok(envelope) = serde_wasm_bindgen::from_value::<HostCommandEnvelope>(detail) else {
return;
};
if envelope.protocol.as_deref() != Some(PROTOCOL) {
return;
}
let Some(payload) = envelope.payload else {
return;
};
let Some(command_kind) = HostCommandKind::from_payload(&payload) else {
return;
};
let _ = mount_id;
let _ = command_kind;
}))
}
#[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,
}
#[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<Storage> {
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<String> {
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<String>) -> Option<String> {
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<String>,
workspace_id: Option<String>,
) -> 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<String>,
explicit_workspace_id: Option<String>,
) -> 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<Option<String>>,
workspace_id: ReadSignal<Option<String>>,
) -> PersistedDocumentIdentity {
persisted_document_identity(
document_id.get_untracked(),
workspace_id.get_untracked(),
)
}
fn persist_document_state(
identity: &PersistedDocumentIdentity,
title: &str,
content: &Value,
html: Option<String>,
) -> 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<PersistedSpikeDocument> {
let raw = local_storage()?
.get_item(&persisted_document_storage_key(identity))
.ok()
.flatten()?;
let document = serde_json::from_str::<PersistedSpikeDocument>(&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<RuntimeDeliveryMode>,
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<String> {
current_query_param("documentId")
}
fn current_workspace_id() -> Option<String> {
current_query_param("workspaceId")
}
fn selection_event_payload(
selection: &TiptapSelectionState,
editor_focused: bool,
hovered_block: Option<HoveredBlockState>,
) -> 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<String>,
workspace_id: Option<String>,
title: String,
dirty_count: u32,
hovered_block: Option<HoveredBlockState>,
editor_focused: bool,
slash_open: bool,
turn_into_open: bool,
color_menu_open: bool,
more_menu_open: bool,
read_only: bool,
) -> 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<String>,
workspace_id: Option<String>,
title: String,
dirty_count: u32,
hovered_block: Option<HoveredBlockState>,
editor_focused: bool,
slash_open: bool,
turn_into_open: bool,
color_menu_open: bool,
more_menu_open: bool,
read_only: bool,
) -> 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<T>(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<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
) -> 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<Element> {
window()
.and_then(|win| win.document())
.and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten())
}
fn editor_stage_element() -> Option<Element> {
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 handle_lane_left(stage_rect_width: f64) -> f64 {
(content_column_left(stage_rect_width)
- HANDLE_TRIGGER_WIDTH
- HANDLE_TRIGGER_GAP
- HANDLE_MENU_GAP)
.max(HANDLE_STAGE_PADDING_LEFT)
}
fn handle_corridor_width(stage_rect_width: f64) -> f64 {
let lane_left = handle_lane_left(stage_rect_width);
let content_left = content_column_left(stage_rect_width);
let content_gap = (content_left - lane_left).max(0.0);
(HANDLE_TRIGGER_WIDTH + HANDLE_TRIGGER_GAP + content_gap).max(HANDLE_GUTTER_WIDTH.min(stage_rect_width))
}
fn direct_block_from_element(mut element: Element, root: &Element) -> Option<Element> {
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<usize> {
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<Element> {
target.clone().dyn_into::<Element>().ok().or_else(|| {
target
.dyn_into::<Node>()
.ok()
.and_then(|node| node.parent_element())
})
}
fn event_target_matches_selector(target: Option<web_sys::EventTarget>, 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<HtmlElement> {
event.current_target()?.dyn_into::<HtmlElement>().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<HoveredBlockState> {
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<HoveredBlockState> {
let selection = window().and_then(|win| win.get_selection().ok().flatten())?;
let anchor_node = selection.anchor_node()?;
let element = anchor_node
.dyn_ref::<Element>()
.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<HoveredBlockState> {
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<u32> {
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<u32> {
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<TiptapRange> {
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<web_sys::Selection> {
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<FloatingToolbarAnchor> {
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)
}
}
fn hover_anchor_transform(block: &HoveredBlockState) -> &'static str {
if block.height > HANDLE_MULTILINE_HEIGHT {
"translateY(0)"
} else {
"translateY(-50%)"
}
}
fn pointer_in_handle_corridor(client_x: i32, client_y: i32, block: &HoveredBlockState) -> bool {
let Some(stage) = editor_stage_element() else {
return false;
};
let stage_rect = stage.get_bounding_client_rect();
let x = f64::from(client_x) - stage_rect.left();
let y = f64::from(client_y) - stage_rect.top();
let block_top = block.top;
let block_bottom = block.top + block.height;
let corridor_left = handle_lane_left(stage_rect.width());
let corridor_right = corridor_left + handle_corridor_width(stage_rect.width());
x >= corridor_left
&& x <= corridor_right
&& y >= block_top - 8.0
&& y <= block_bottom + 8.0
}
fn block_menu_layout(block: &HoveredBlockState) -> Option<BlockMenuLayout> {
let stage = editor_stage_element()?;
let stage_rect = stage.get_bounding_client_rect();
let anchor_top = hover_anchor_top(block);
let stage_padding = 18.0;
let space_above = (anchor_top - stage_padding).max(0.0);
let space_below = (stage_rect.height() - anchor_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)
};
Some(BlockMenuLayout {
max_height,
open_upward,
})
}
fn drop_indicator_from_target(
target: web_sys::EventTarget,
client_y: i32,
) -> Option<DropIndicatorState> {
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<DropIndicatorState> {
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<Value>, 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<Map<String, Value>>,
content: Vec<Value>,
) -> 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>) -> Value {
node_with_attrs("paragraph", None, content)
}
fn list_item_node(content: Vec<Value>) -> Value {
node_with_attrs("listItem", None, vec![paragraph_node(content)])
}
fn task_item_node(content: Vec<Value>) -> 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::<Vec<_>>()
.join("")
})
.unwrap_or_default(),
}
}
fn inline_from_first_child(children: &[Value]) -> Vec<Value> {
children
.first()
.map(extract_inline_content)
.unwrap_or_default()
}
fn extract_inline_content(node: &Value) -> Vec<Value> {
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::<Vec<_>>()
})
.unwrap_or_default(),
}
}
fn turn_into_block(node: &Value, action: SlashActionKind) -> Value {
let inline = extract_inline_content(node);
match action {
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::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 => 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<bool>,
set_floating_toolbar_anchor: WriteSignal<Option<FloatingToolbarAnchor>>,
) -> 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<bool>,
set_text_selection_active: WriteSignal<bool>,
set_floating_toolbar_anchor: WriteSignal<Option<FloatingToolbarAnchor>>,
set_slash_open: WriteSignal<bool>,
set_turn_into_open: WriteSignal<bool>,
set_hovered_block: WriteSignal<Option<HoveredBlockState>>,
set_block_menu_anchor: WriteSignal<Option<HoveredBlockState>>,
set_block_menu_open: WriteSignal<bool>,
) {
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<bool>,
set_text_selection_active: WriteSignal<bool>,
set_floating_toolbar_anchor: WriteSignal<Option<FloatingToolbarAnchor>>,
set_slash_open: WriteSignal<bool>,
set_turn_into_open: WriteSignal<bool>,
set_hovered_block: WriteSignal<Option<HoveredBlockState>>,
set_block_menu_anchor: WriteSignal<Option<HoveredBlockState>>,
set_block_menu_open: WriteSignal<bool>,
) -> 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<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
success_message: impl Into<String>,
) {
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<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
success_message: impl Into<String>,
) {
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<String, String> {
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::<Element>()
.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 insert_top_level_paragraph_after_html(
current_html: &str,
index: usize,
) -> Result<String, String> {
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();
children
.item(index as u32)
.ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?;
let paragraph = document
.create_element("p")
.map_err(|err| format!("创建空段落失败:{err:?}"))?;
if let Some(next_sibling) = children.item(index as u32 + 1) {
container
.insert_before(&paragraph, Some(&next_sibling))
.map_err(|err| format!("插入新块失败:{err:?}"))?;
} else {
container
.append_child(&paragraph)
.map_err(|err| format!("追加新块失败:{err:?}"))?;
}
Ok(container.inner_html())
}
fn delete_top_level_block_html(current_html: &str, index: usize) -> Result<String, String> {
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("<p></p>");
return Ok(container.inner_html());
}
let current = children
.item(index as u32)
.ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?;
container
.remove_child(&current)
.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<String, String> {
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::<Element>()
.map_err(|_| "复制出的拖拽块不是 Element".to_string())?;
container
.remove_child(&current)
.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<TiptapExtension> {
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<usize>) -> 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<usize>,
) -> 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<String>,
workspace_id: Option<String>,
title: String,
dirty_count: u32,
hovered_block: Option<HoveredBlockState>,
editor_focused: bool,
slash_open: bool,
turn_into_open: bool,
color_menu_open: bool,
more_menu_open: bool,
read_only: bool,
) -> 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<String>,
workspace_id: Option<String>,
title: String,
dirty_count: u32,
hovered_block: Option<HoveredBlockState>,
editor_focused: bool,
slash_open: bool,
turn_into_open: bool,
color_menu_open: bool,
more_menu_open: bool,
read_only: bool,
) -> 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<String>,
workspace_id: Option<String>,
title: String,
dirty_count: u32,
hovered_block: Option<HoveredBlockState>,
editor_focused: bool,
slash_open: bool,
turn_into_open: bool,
color_menu_open: bool,
more_menu_open: bool,
read_only: bool,
) {
let state = state_payload(
document_id.clone(),
workspace_id.clone(),
title.clone(),
dirty_count,
hovered_block.clone(),
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
read_only,
);
dispatch_state_event(&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<usize>,
) {
dispatch_selection_event(&selection_payload(
selection,
editor_focused,
current_block_index,
));
}
fn apply_host_document_payload(
editor: TiptapEditorHandle,
payload: HostDocumentPayload,
set_document_id: WriteSignal<Option<String>>,
set_workspace_id: WriteSignal<Option<String>>,
set_title: WriteSignal<String>,
set_read_only: WriteSignal<bool>,
set_revision: WriteSignal<Option<i64>>,
set_conflict_detection_key: WriteSignal<Option<String>>,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
set_command_feedback: WriteSignal<String>,
) {
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::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::BulletList => editor.toggle_bullet_list(),
SlashActionKind::OrderedList => editor.toggle_ordered_list(),
SlashActionKind::Todo => 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::Paragraph => "已切到段落",
SlashActionKind::Heading1 => "已切到一级标题",
SlashActionKind::Heading2 => "已切到二级标题",
SlashActionKind::Heading3 => "已切到三级标题",
SlashActionKind::BulletList => "已切到无序列表",
SlashActionKind::OrderedList => "已切到有序列表",
SlashActionKind::Todo => "已切到 Todo 列表",
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 apply_feedback_result<E>(setter: WriteSignal<String>, 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<E>(
setter: WriteSignal<String>,
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>) -> 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::<FloatingToolbarAnchor>);
let (locked_toolbar_anchor, set_locked_toolbar_anchor) = signal(None::<FloatingToolbarAnchor>);
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::<HoveredBlockState>);
let (block_menu_anchor, set_block_menu_anchor) = signal(None::<HoveredBlockState>);
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::<PendingDragState>);
let (dragging_block_index, set_dragging_block_index) = signal(None::<usize>);
let (dragging_block_anchor, set_dragging_block_anchor) = signal(None::<HoveredBlockState>);
let (drop_indicator, set_drop_indicator) = signal(None::<DropIndicatorState>);
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::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
return;
};
let detail = custom_event.detail();
let Ok(envelope) = serde_wasm_bindgen::from_value::<HostCommandEnvelope>(detail) else {
return;
};
if envelope.protocol.as_deref() != Some(PROTOCOL) {
return;
}
let Some(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(
&current_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 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 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" {
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);
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_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);
}
"Escape" => {
event.prevent_default();
let _ = set_slash_open.try_set(false);
let _ = set_command_feedback.try_set("Slash 菜单已关闭".to_string());
}
_ => {}
}
});
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! {
<style>{SPIKE_STYLE}</style>
<main
class=move || if is_embedded { "app-shell app-shell-embedded" } else { "app-shell" }
data-testid="mnote-leptos-tiptap-host"
data-mnote-runtime=RUNTIME_NAME
data-mnote-runtime-bridge="true"
>
{move || {
if is_embedded {
().into_any()
} else {
view! {
<section class="hero-bar">
<div class="hero-meta">
<span class="hero-tag">"P0 / Tiptap-like 主编辑器体验"</span>
<h1 class="hero-title">"把 Spike 收敛成能检验的最小产品页壳"</h1>
<p class="hero-copy">
"这里继续只做 "
<code>"8123"</code>
" 的 Leptos + Tiptap P0 体验验证,不挪动正式 "
<code>"/documents/[id]"</code>
" 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。"
</p>
</div>
<div class="hero-actions">
<div class="hero-chip">
<span>"变更计数"</span>
<strong>{move || dirty_count.get().to_string()}</strong>
</div>
<div class="hero-chip" id="todo-schema-status">
<span>"Todo Schema"</span>
<strong>{move || if has_task_schema() { "ready" } else { "pending" }}</strong>
</div>
<div class="hero-chip" id="selection-summary-chip">
<span>"Selection"</span>
<strong>{selection_text}</strong>
</div>
</div>
</section>
}.into_any()
}
}}
<section class="editor-card">
{move || {
if is_embedded {
().into_any()
} else {
view! {
<>
<div class="editor-topbar">
<div class="editor-crumbs">
<span>"空间 / 主文档 /"</span>
<strong>"Editor Baseline Reset"</strong>
</div>
<div class="editor-crumbs">
<span>"反馈:"</span>
<strong id="command-feedback">{move || command_feedback.get()}</strong>
</div>
</div>
<input
class="title-input"
prop:value=move || title.get()
on:input=move |event| {
let next_title = event_target_value(&event);
set_title.set(next_title.clone());
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&next_title,
&document_json.get_untracked(),
Some(html_output.get_untracked()),
) {
Ok(()) => set_command_feedback.set("标题已写入本地草稿".to_string()),
Err(err) => set_command_feedback.set(format!("标题保存失败:{err}")),
}
}
placeholder="给这篇页面起个标题"
aria-label="Spike 页面标题"
/>
<div class="editor-subcopy">
<span class="hint-pill">"输入 “/” 打开真正的最小 slash 菜单"</span>
<span class="hint-pill">"选中文本时会出现最小浮动工具条"</span>
<span class="hint-pill">"Todo 现在走 taskList / taskItem,而不是占位 HTML"</span>
<span class="hint-pill">"把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排"</span>
<span class="hint-pill">"本页会把 JSON 草稿写入 localStorage,刷新后不丢结构"</span>
</div>
</>
}.into_any()
}
}}
<div
id="editor-stage"
class="editor-stage"
data-testid="mnote-leptos-tiptap-editor-stage"
data-page-wide-layout=move || wide_layout.get().to_string()
data-page-show-heading-numbers=move || show_heading_numbers.get().to_string()
data-page-embed-default-block-id=move || embed_default_block_id.get().unwrap_or_default()
on:click=move |event: MouseEvent| {
let Some(target) = event.target() else {
return;
};
if let Some(element) = target_element(target.clone()) {
if element.closest(HANDLE_SHELL_SELECTOR).ok().flatten().is_some() {
return;
}
}
set_editor_focused.set(true);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_turn_into_open.set(false);
set_hovered_block.set(hovered_block_from_target(target));
}
on:mousemove=move |event: MouseEvent| {
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
return;
}
let Some(target) = event.target() else {
return;
};
if let Some(element) = target_element(target.clone()) {
if element.closest(HANDLE_SHELL_SELECTOR).ok().flatten().is_some() {
return;
}
}
set_editor_focused.set(true);
if let Some(block) = hovered_block_from_target(target) {
set_hovered_block.set(Some(block));
return;
}
if let Some(current_block) = hovered_block.get_untracked() {
if pointer_in_handle_corridor(event.client_x(), event.client_y(), &current_block) {
return;
}
}
set_hovered_block.set(None);
}
on:mouseover=move |event: MouseEvent| {
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
return;
}
let Some(target) = event.target() else {
return;
};
if let Some(block) = hovered_block_from_target(target) {
set_editor_focused.set(true);
set_hovered_block.set(Some(block));
}
}
on:mouseleave=move |_| {
if !block_menu_open.get_untracked() && dragging_block_index.get_untracked().is_none() {
set_hovered_block.set(None);
}
}
on:dragover=move |event: DragEvent| {
if dragging_block_index.get_untracked().is_none() {
return;
}
event.prevent_default();
let Some(target) = event.target() else {
return;
};
if let Some(element) = target_element(target.clone()) {
if element.closest(HANDLE_SHELL_SELECTOR).ok().flatten().is_some() {
return;
}
}
set_drop_indicator.set(drop_indicator_from_target(target, event.client_y()));
}
on:drop=move |event: DragEvent| {
event.prevent_default();
let Some(source_index) = dragging_block_index.get_untracked() else {
return;
};
let Some(target) = event.target() else {
set_dragging_block_index.set(None);
set_dragging_block_anchor.set(None);
set_drop_indicator.set(None);
return;
};
let Some(indicator) = drop_indicator_from_target(target, event.client_y()) else {
set_dragging_block_index.set(None);
set_dragging_block_anchor.set(None);
set_drop_indicator.set(None);
return;
};
match editor.get_html() {
Ok(current_html) => match reorder_top_level_block_html(
&current_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! {
<div
class="floating-toolbar"
data-testid="mnote-leptos-tiptap-toolbar"
style:top=format!("{}px", anchor.top)
style:left=format!("{}px", anchor.left)
on:mousedown=move |event: MouseEvent| {
if event_target_matches_selector(
event.target(),
".toolbar-btn, .toolbar-menu-item, .turn-into-item",
) {
event.prevent_default();
}
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
>
<div class="floating-toolbar-group">
<div class="toolbar-popover-anchor">
<button
class="toolbar-btn"
data-wide="true"
data-active=move || turn_into_open.get().to_string()
data-testid="toolbar-turn-into"
on:click=move |_| {
let next_open = !turn_into_open.get_untracked();
if next_open {
set_locked_toolbar_anchor
.set(floating_toolbar_anchor.get_untracked());
}
set_turn_into_open.set(next_open);
set_block_menu_open.set(false);
}
>
"Text"
</button>
<div
class="turn-into-panel"
data-testid="turn-into-panel"
style:display=move || if turn_into_open.get() { "grid" } else { "none" }
on:mousedown=move |event: MouseEvent| {
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
on:wheel=move |event: WheelEvent| {
trap_scroll_inside_menu(&event);
}
>
{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! {
<button
class="turn-into-item"
data-testid=testid
on:click=move |_| {
apply_feedback_result(set_command_feedback, run_slash_action(editor, kind));
set_turn_into_open.set(false);
}
>
{label}
<span>{description}</span>
</button>
}
}).collect_view()}
</div>
</div>
</div>
<div class="toolbar-separator"></div>
<div class="floating-toolbar-group">
<button
class="toolbar-btn"
data-active=move || selection_state.get().bold.to_string()
data-testid="toolbar-bold"
on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_bold(), "已更新行内样式")
>
"B"
</button>
<button
class="toolbar-btn"
data-active=move || selection_state.get().italic.to_string()
data-testid="toolbar-italic"
on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_italic(), "已更新行内样式")
>
"I"
</button>
<button
class="toolbar-btn"
data-active=move || selection_state.get().underline.to_string()
data-testid="toolbar-underline"
on:click=move |_| apply_ok_feedback(
set_command_feedback,
editor.toggle_mark(TiptapMarkName::Underline, None, None),
"已更新行内样式",
)
>
"U"
</button>
<button
class="toolbar-btn"
data-active=move || selection_state.get().strike.to_string()
data-testid="toolbar-strike"
on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_strike(), "已更新行内样式")
>
"S"
</button>
<button
class="toolbar-btn"
data-active="false"
data-testid="toolbar-code"
on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_code(), "已更新行内样式")
>
"</>"
</button>
<button
class="toolbar-btn"
data-active=move || selection_state.get().link.to_string()
data-testid="toolbar-link"
on:click=on_link_click
>
"Link"
</button>
<div class="toolbar-popover-anchor">
<button
class="toolbar-btn"
data-wide="true"
data-active=move || (selection_state.get().text_style
|| selection_state.get().highlight
|| color_menu_open.get()).to_string()
data-testid="toolbar-color"
on:click=move |_| {
let next_open = !color_menu_open.get_untracked();
if next_open {
set_locked_toolbar_anchor
.set(floating_toolbar_anchor.get_untracked());
}
set_color_menu_open.set(next_open);
set_more_menu_open.set(false);
set_turn_into_open.set(false);
}
>
"Color"
</button>
<div
class="toolbar-popover-panel"
data-testid="toolbar-color-panel"
style:display=move || if color_menu_open.get() { "grid" } else { "none" }
on:mousedown=move |event: MouseEvent| {
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
on:wheel=move |event: WheelEvent| {
trap_scroll_inside_menu(&event);
}
>
<div class="toolbar-popover-section">
<div class="toolbar-popover-title">"文字颜色"</div>
<div class="toolbar-color-grid">
{TOOLBAR_TEXT_COLORS.iter().map(|option| {
let value = option.value;
let label = option.label;
let testid = format!("toolbar-text-color-{}", option.id);
view! {
<button
class="toolbar-menu-item toolbar-color-item"
data-testid=testid
on:click=move |_| {
apply_feedback_result(
set_command_feedback,
apply_text_color(editor, value),
);
set_color_menu_open.set(false);
}
>
<span
class="toolbar-color-chip"
style:background=value
></span>
<strong>{label}</strong>
</button>
}
}).collect_view()}
</div>
</div>
<div class="toolbar-popover-section">
<div class="toolbar-popover-title">"背景颜色"</div>
<div class="toolbar-color-grid">
{TOOLBAR_HIGHLIGHT_COLORS.iter().map(|option| {
let value = option.value;
let label = option.label;
let testid = format!("toolbar-highlight-color-{}", option.id);
view! {
<button
class="toolbar-menu-item toolbar-color-item"
data-testid=testid
on:click=move |_| {
apply_feedback_result(
set_command_feedback,
apply_highlight_color(editor, Some(value)),
);
set_color_menu_open.set(false);
}
>
<span
class="toolbar-color-chip"
style:background=value
></span>
<strong>{label}</strong>
</button>
}
}).collect_view()}
</div>
</div>
<div class="toolbar-popover-section">
<button
class="toolbar-menu-item"
data-testid="toolbar-clear-color"
on:click=move |_| {
apply_feedback_result(
set_command_feedback,
clear_text_color(editor),
);
let _ = apply_highlight_color(editor, None);
set_color_menu_open.set(false);
}
>
<strong>"清除颜色"</strong>
<span>"移除文字色与背景色"</span>
</button>
</div>
</div>
</div>
</div>
<div class="toolbar-separator"></div>
<div class="floating-toolbar-group">
<div class="toolbar-popover-anchor">
<button
class="toolbar-btn"
data-wide="true"
data-active=move || (selection_state.get().align_center
|| selection_state.get().align_right
|| selection_state.get().align_justify
|| more_menu_open.get()).to_string()
data-testid="toolbar-more"
on:click=move |_| {
let next_open = !more_menu_open.get_untracked();
if next_open {
set_locked_toolbar_anchor
.set(floating_toolbar_anchor.get_untracked());
}
set_more_menu_open.set(next_open);
set_color_menu_open.set(false);
set_turn_into_open.set(false);
}
>
"More"
</button>
<div
class="toolbar-popover-panel"
data-testid="toolbar-more-panel"
style:display=move || if more_menu_open.get() { "grid" } else { "none" }
on:mousedown=move |event: MouseEvent| {
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
on:wheel=move |event: WheelEvent| {
trap_scroll_inside_menu(&event);
}
>
<div class="toolbar-popover-section">
<div class="toolbar-popover-title">"对齐方式"</div>
<div class="toolbar-more-list">
{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! {
<button
class="toolbar-menu-item toolbar-more-item"
data-active=active.to_string()
data-testid=testid
on:click=move |_| {
apply_feedback_result(
set_command_feedback,
apply_text_align(editor, alignment),
);
set_more_menu_open.set(false);
}
>
<strong>{label}</strong>
<span>"按官方浮动工具条对当前块对齐"</span>
</button>
}
}).collect_view()}
</div>
</div>
</div>
</div>
</div>
</div>
}.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! {
<div
class="block-drop-indicator"
data-testid="block-drop-indicator"
style:top=format!("{}px", indicator.top)
style:left=drop_indicator_left
style:right=drop_indicator_right
></div>
}.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 editor_focused.get() && !text_selection_active.get() && !slash_open.get() {
block_state_from_index(0)
} 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 menu_block_label = block_label.clone();
let duplicate_block_label = block_label.clone();
let delete_block_label = block_label.clone();
let insert_block_label = block_label.clone();
let click_anchor_block = block.clone();
let drag_anchor_block = block.clone();
view! {
<div
class="block-handle-shell"
data-testid="mnote-leptos-tiptap-handle"
data-dragging=move || if is_dragging { "true" } else { "false" }
style:left=handle_left
style:top=anchor_top
style:transform=anchor_transform
>
<button
class="block-handle-insert"
data-testid="block-insert-trigger"
aria-label="Insert block"
title="Insert block"
on:click=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
set_turn_into_open.set(false);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
match editor.get_html() {
Ok(current_html) => match insert_top_level_paragraph_after_html(&current_html, block_index) {
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,
format!("已在 {} 后插入新块", insert_block_label),
);
set_slash_open.set(false);
set_slash_index.set(0);
set_hovered_block.set(block_state_from_index(block_index + 1));
}
Err(err) => {
set_command_feedback.set(err);
}
},
Err(err) => {
set_command_feedback.set(format!("读取当前 HTML 失败:{err}"));
}
}
}
>
<span>"+"</span>
</button>
<button
class="block-handle-trigger"
data-testid="block-drag-handle-trigger"
aria-label="Open block menu"
title="Open block menu"
on:mousedown=move |event: MouseEvent| {
if event.button() != 0 {
return;
}
event.stop_propagation();
set_pending_drag.set(Some(PendingDragState {
index: block_index,
anchor: drag_anchor_block.clone(),
start_x: event.client_x(),
start_y: event.client_y(),
}));
set_suppress_handle_click.set(false);
}
on:click=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
if suppress_handle_click.get_untracked() {
set_suppress_handle_click.set(false);
return;
}
set_turn_into_open.set(false);
set_slash_open.set(false);
let same_anchor = block_menu_anchor
.get_untracked()
.map(|current| current.index == block_index)
.unwrap_or(false);
let should_open = !block_menu_open.get_untracked() || !same_anchor;
set_block_menu_open.set(should_open);
set_block_turn_into_open.set(false);
if should_open {
set_block_menu_anchor.set(Some(click_anchor_block.clone()));
} else {
set_block_menu_anchor.set(None);
}
set_command_feedback.set(format!("已定位块菜单:{}", click_block_label));
}
>
{if is_dragging { "⋯" } else { "⠿" }}
</button>
{move || {
if block_menu_open.get()
&& block_menu_anchor
.get()
.map(|current| current.index == block_index)
.unwrap_or(false)
{
let current_menu_label = menu_block_label.clone();
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,
});
let menu_top = if menu_layout.open_upward { "auto" } else { "0" };
let menu_bottom = if menu_layout.open_upward { "0" } else { "auto" };
view! {
<div
class="block-drag-menu"
data-testid="block-drag-menu"
style:top=menu_top
style:bottom=menu_bottom
style:max-height=format!("{}px", menu_layout.max_height)
on:mousedown=move |event: MouseEvent| {
event.stop_propagation();
}
on:mouseup=move |event: MouseEvent| {
event.stop_propagation();
}
on:mousemove=move |event: MouseEvent| {
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
on:wheel=move |event: WheelEvent| {
trap_scroll_inside_menu(&event);
}
>
<div class="block-drag-menu-label" data-testid="block-drag-menu-label">
<strong>{current_menu_label.clone()}</strong>
<span>{format!("顶层块 #{}", block_index + 1)}</span>
</div>
<div class="block-drag-menu-actions">
{move || {
if block_turn_into_open.get() {
view! {
<div class="block-drag-menu-section">
<button
class="block-drag-menu-back"
data-testid="block-drag-menu-back"
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_block_turn_into_open.set(false);
}
>
"← 返回"
</button>
{SLASH_ACTIONS
.iter()
.filter(|action| action.kind != SlashActionKind::Divider)
.map(|action| {
let kind = action.kind;
let label = action.label;
let testid = format!("block-drag-menu-item-{}", action.id);
let action_menu_label = current_menu_label.clone();
view! {
<button
class="block-drag-menu-item"
data-testid=testid
on:click=move |event: MouseEvent| {
event.stop_propagation();
match run_block_turn_into_action(editor, block_index, kind) {
Ok(message) => {
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
let (html, snapshot, json_text) = read_editor_snapshot(editor);
set_dirty_count.update(|count| *count += 1);
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
&snapshot,
Some(html),
) {
Ok(()) => {
set_command_feedback.set(format!(
"{}{} -> {},并已写入本地草稿",
message,
action_menu_label.clone(),
label,
));
}
Err(err) => {
set_command_feedback.set(format!(
"{}{} -> {},但本地保存失败:{}",
message,
action_menu_label.clone(),
label,
err,
));
}
}
}
Err(err) => {
set_command_feedback.set(err);
}
}
}
>
{label}
</button>
}
})
.collect_view()}
</div>
}.into_any()
} else {
let duplicate_feedback_label = current_duplicate_label.clone();
let delete_feedback_label = current_delete_label.clone();
view! {
<>
<div class="block-drag-menu-section">
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-turn-into"
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_block_turn_into_open.set(true);
}
>
"Turn Into"
</button>
</div>
<div class="block-drag-menu-divider"></div>
<div class="block-drag-menu-section">
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-duplicate"
on:click=move |event: MouseEvent| {
event.stop_propagation();
match editor.get_html() {
Ok(current_html) => match duplicate_top_level_block_html(&current_html, block_index) {
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,
format!("已复制 {}", duplicate_feedback_label.clone()),
);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
}
Err(err) => set_command_feedback.set(err),
},
Err(err) => set_command_feedback.set(format!("读取当前 HTML 失败:{err}")),
}
}
>
"Duplicate"
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-delete"
on:click=move |event: MouseEvent| {
event.stop_propagation();
match editor.get_html() {
Ok(current_html) => match delete_top_level_block_html(&current_html, block_index) {
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,
format!("已删除 {}", delete_feedback_label.clone()),
);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_hovered_block.set(None);
}
Err(err) => set_command_feedback.set(err),
},
Err(err) => set_command_feedback.set(format!("读取当前 HTML 失败:{err}")),
}
}
>
"Delete"
</button>
</div>
</>
}.into_any()
}
}}
</div>
</div>
}.into_any()
} else {
().into_any()
}
}}
</div>
}.into_any()
} else {
().into_any()
}
}}
{move || {
if slash_open.get() {
view! {
<div class="slash-menu" data-testid="mnote-leptos-tiptap-slash-menu">
<div class="slash-list">
{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 testid = format!("slash-item-{}", action.id);
view! {
<button
class="slash-item"
data-active=move || is_selected().to_string()
data-testid=testid
on:click=move |_| {
apply_feedback_result(set_command_feedback, run_slash_action(editor, kind));
set_slash_open.set(false);
}
>
{label}
<span>{description}</span>
</button>
}
}).collect_view()}
</div>
</div>
}
.into_any()
} else {
().into_any()
}
}}
<TiptapEditor
id="mnote-leptos-tiptap-spike"
editor=editor
initial_content=initial_editor_content.clone()
placeholder="输入 “/” 打开命令菜单;试试 heading / list / todo / quote / code block / divider"
disabled=move || !editor_editable.get()
extensions=p0_extensions()
on_ready=move |_| {
if restored_from_storage {
let restored_content = restored_html
.clone()
.map(TiptapContent::html)
.unwrap_or_else(|| {
let restored_json = document_json.get_untracked();
let payload = restored_json
.get("content")
.and_then(Value::as_array)
.cloned()
.map(Value::Array)
.unwrap_or(restored_json);
TiptapContent::json(payload)
});
if let Err(err) = editor.set_content(restored_content) {
set_command_feedback.set(format!("恢复本地草稿失败:{err}"));
return;
}
}
let snapshot =
sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
dispatch_ready_event(&ReadyPayload {
runtime_name: RUNTIME_NAME,
selectors: BridgeSelectorsPayload {
root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
},
supported_commands: vec![
"replaceContent",
"setEditable",
"undo",
"redo",
"focus",
"requestCurrentBlockId",
],
supports_embedded_mode: true,
});
dispatch_runtime_state(
document_id.get_untracked(),
workspace_id.get_untracked(),
title.get_untracked(),
dirty_count.get_untracked(),
hovered_block.get_untracked(),
editor_focused.get_untracked(),
slash_open.get_untracked(),
turn_into_open.get_untracked(),
color_menu_open.get_untracked(),
more_menu_open.get_untracked(),
read_only.get_untracked(),
);
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
&snapshot,
Some(html_output.get_untracked()),
) {
Ok(()) => {
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()
/>
</div>
{move || {
if is_embedded {
().into_any()
} else {
view! {
<div class="footer-strip">
<span>
"当前 P0 边界:"
<code>"paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider"</code>
</span>
<span>
"运行时扩展不再走 "
<code>"TiptapExtension::all_enabled()"</code>
",这里已冻结为最小集合;当前 reload 走 "
<code>"localStorage JSON"</code>
",块菜单支持复制、删除、turn into 与顶层拖拽。"
</span>
</div>
}.into_any()
}
}}
</section>
{move || {
if is_embedded {
().into_any()
} else {
view! {
<details class="debug-drawer">
<summary>
<span>"调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面"</span>
<span>"展开 / 收起"</span>
</summary>
<div class="debug-grid">
<section class="debug-card">
<h3>"Selection"</h3>
<pre id="editor-selection-summary">{selection_text}</pre>
</section>
<section class="debug-card">
<h3>"HTML"</h3>
<pre id="editor-html-output">{move || html_output.get()}</pre>
</section>
<section class="debug-card">
<h3>"JSON"</h3>
<pre id="editor-json-output">{move || json_output.get()}</pre>
</section>
</div>
</details>
}.into_any()
}
}}
</main>
}
}
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! { <App mount_options=MountOptions::default()/> }
});
}