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

3741 lines
150 KiB
Rust
Raw Normal View History

use leptos::ev;
use leptos::mount::mount_to_body;
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::fmt::Display;
use wasm_bindgen::JsCast;
use web_sys::{window, DragEvent, Element, 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;
#[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;
}
.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;
margin: 0 18px 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);
}
.block-handle-shell {
position: absolute;
left: 18px;
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: 88px;
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: 14px;
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-menu header {
display: grid;
gap: 4px;
}
.slash-menu h2 {
margin: 0;
font-size: 15px;
}
.slash-menu p {
margin: 0;
color: var(--muted);
line-height: 1.6;
font-size: 13px;
}
.slash-list {
display: grid;
gap: 6px;
}
.editor-surface {
display: block;
min-height: 580px;
padding: 76px 40px 32px 92px;
}
.editor-surface .ProseMirror {
outline: none;
min-height: 472px;
line-height: 1.78;
font-size: 17px;
color: #1f2937;
}
.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;
}
.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 20px 24px 28px;
min-height: 460px;
}
.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) {
.block-handle-shell,
.block-drop-indicator {
display: none;
}
}
"#;
#[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 {
title: String,
content: Value,
#[serde(default)]
html: Option<String>,
}
#[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 persist_document_state(
title: &str,
content: &Value,
html: Option<String>,
) -> Result<(), String> {
let payload = PersistedSpikeDocument {
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(SPIKE_STORAGE_KEY, &raw)
.map_err(|err| format!("写入 localStorage 失败:{err:?}"))
}
fn load_persisted_document() -> Option<PersistedSpikeDocument> {
let raw = local_storage()?
.get_item(SPIKE_STORAGE_KEY)
.ok()
.flatten()?;
serde_json::from_str(&raw).ok()
}
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 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())
})
}
2026-04-19 22:09:13 +08:00
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();
Some(HoveredBlockState {
index,
label: block_label_for_element(&block),
top: block_rect.top() - stage_rect.top(),
height: block_rect.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;
x >= 0.0 && x <= HANDLE_GUTTER_WIDTH && 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 apply_document_update(
editor: TiptapEditorHandle,
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(&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,
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(&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 {
window()
.and_then(|win| win.document())
.and_then(|document| document.active_element())
.and_then(|element| element.closest(EDITOR_STAGE_SELECTOR).ok().flatten())
.is_some()
}
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 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));
}
#[component]
fn App() -> impl IntoView {
let editor = TiptapEditorHandle::new();
let persisted = load_persisted_document();
let restored_from_storage = persisted.is_some();
let restored_html = persisted
.as_ref()
.and_then(|document| document.html.clone());
let initial_title_value = persisted
.as_ref()
.map(|document| document.title.clone())
.unwrap_or_else(default_title);
let initial_editor_content = 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 (title, set_title) = signal(initial_title_value);
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>);
2026-04-19 22:09:13 +08:00
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 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");
}
});
}
{
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);
}
});
}
2026-04-19 22:09:13 +08:00
{
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(),
);
2026-04-19 22:09:13 +08:00
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.get_untracked() {
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,
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}"));
}
}
} else {
set_command_feedback.set("块拖拽已取消".to_string());
}
set_dragging_block_index.set(None);
set_dragging_block_anchor.set(None);
set_drop_indicator.set(None);
set_pending_drag.set(None);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_hovered_block.set(None);
set_suppress_handle_click.set(true);
} else if pending_drag.get_untracked().is_some() {
set_pending_drag.set(None);
}
if let Some(target) = event.target() {
if let Some(element) = target_element(target) {
if element
2026-04-19 22:09:13 +08:00
.closest(&format!("{HANDLE_SHELL_SELECTOR}, .floating-toolbar"))
.ok()
.flatten()
.is_some()
{
return;
}
}
}
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 |_| {
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.get_untracked().is_some() {
set_editor_focused.set(true);
set_drop_indicator.set(drop_indicator_from_point(
event.client_x(),
event.client_y(),
));
return;
}
let Some(pending) = pending_drag.get_untracked() else {
return;
};
if event.buttons() & 1 == 0 {
set_pending_drag.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;
}
set_editor_focused.set(true);
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(Some(pending.anchor.clone()));
set_dragging_block_index.set(Some(pending.index));
set_dragging_block_anchor.set(Some(
dragging_block_anchor
.get_untracked()
.unwrap_or_else(|| pending.anchor.clone()),
));
set_pending_drag.set(None);
set_drop_indicator.set(drop_indicator_from_point(
event.client_x(),
event.client_y(),
));
set_command_feedback.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" {
set_slash_open.set(false);
set_turn_into_open.set(false);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
return;
}
if event.key() == "/" && !event.ctrl_key() && !event.meta_key() && !event.alt_key() {
event.prevent_default();
set_slash_open.set(true);
set_turn_into_open.set(false);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_slash_index.set(0);
set_command_feedback.set("Slash 菜单已打开".to_string());
return;
}
if !slash_open.get_untracked() {
return;
}
match event.key().as_str() {
"ArrowDown" => {
event.prevent_default();
let next = (slash_index.get_untracked() + 1) % SLASH_ACTIONS.len();
set_slash_index.set(next);
}
"ArrowUp" => {
event.prevent_default();
let current = slash_index.get_untracked();
let next = if current == 0 {
SLASH_ACTIONS.len() - 1
} else {
current - 1
};
set_slash_index.set(next);
}
"Enter" => {
event.prevent_default();
let action = SLASH_ACTIONS[slash_index.get_untracked()].kind;
match run_slash_action(editor, action) {
Ok(message) => set_command_feedback.set(message.to_string()),
Err(message) => set_command_feedback.set(message),
}
set_slash_open.set(false);
}
"Escape" => {
event.prevent_default();
set_slash_open.set(false);
set_command_feedback.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 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="app-shell">
<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>
<section class="editor-card">
<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(
&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>
<div
id="editor-stage"
class="editor-stage"
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: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,
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 || {
2026-04-19 22:09:13 +08:00
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="floating-toolbar"
style:top=format!("{}px", anchor.top)
style:left=format!("{}px", anchor.left)
on:mousedown=move |event: MouseEvent| {
2026-04-19 22:09:13 +08:00
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 |_| {
2026-04-19 22:09:13 +08:00
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>
2026-04-19 22:09:13 +08:00
<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! {
2026-04-19 22:09:13 +08:00
<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);
}
>
2026-04-19 22:09:13 +08:00
{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 |_| {
2026-04-19 22:09:13 +08:00
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>
2026-04-19 22:09:13 +08:00
<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
2026-04-19 22:09:13 +08:00
class="toolbar-menu-item toolbar-color-item"
data-testid=testid
on:click=move |_| {
apply_feedback_result(
set_command_feedback,
2026-04-19 22:09:13 +08:00
apply_text_color(editor, value),
);
set_color_menu_open.set(false);
}
>
2026-04-19 22:09:13 +08:00
<span
class="toolbar-color-chip"
style:background=value
></span>
<strong>{label}</strong>
</button>
2026-04-19 22:09:13 +08:00
}
}).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 |_| {
2026-04-19 22:09:13 +08:00
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>
2026-04-19 22:09:13 +08:00
<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();
}
2026-04-19 22:09:13 +08:00
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() {
view! {
<div
class="block-drop-indicator"
data-testid="block-drop-indicator"
style:top=format!("{}px", indicator.top)
></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()
};
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 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="block-drag-handle"
data-dragging=move || if is_dragging { "true" } else { "false" }
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,
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(
&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,
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,
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="slash-menu">
<header>
<h2>"Slash 菜单"</h2>
<p>
"输入 "
<code>"/"</code>
" 后,用方向键选择,按回车执行。这里只保留 P0 需要的十个块命令。"
</p>
</header>
<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"
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);
match persist_document_state(
&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);
match persist_document_state(
&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| {
set_selection_state.set(selection);
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);
}
}
attr:class="editor-surface"
/>
</div>
<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>
</section>
<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>
</main>
}
}
fn initial_content() -> TiptapContent {
TiptapContent::html(
r#"
<h1>准备接管主编辑器体验</h1>
<p>这页现在先只在 <strong>8123</strong> 做 P0 体验收口:你可以输入 <code>/</code> 打开命令菜单,也可以直接试试选中文字后的浮动工具条。</p>
<p>目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。</p>
<ul>
<li>标题、段落、引用、代码块</li>
<li>无序列表 / 有序列表 / Todo 列表</li>
<li>分割线、行内样式、链接和高亮</li>
</ul>
<p>请把光标放到这里,然后按 <code>/</code>。</p>
"#,
)
}
fn main() {
console_error_panic_hook::set_once();
mount_to_body(|| {
view! { <App/> }
});
}