3716 lines
150 KiB
Rust
3716 lines
150 KiB
Rust
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())
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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(¶graph, Some(&next_sibling))
|
|||
|
|
.map_err(|err| format!("插入新块失败:{err:?}"))?;
|
|||
|
|
} else {
|
|||
|
|
container
|
|||
|
|
.append_child(¶graph)
|
|||
|
|
.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(¤t)
|
|||
|
|
.map_err(|err| format!("删除 HTML 块失败:{err:?}"))?;
|
|||
|
|
Ok(container.inner_html())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn reorder_top_level_block_html(
|
|||
|
|
current_html: &str,
|
|||
|
|
source: usize,
|
|||
|
|
target: usize,
|
|||
|
|
placement: DropPlacement,
|
|||
|
|
) -> Result<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(¤t)
|
|||
|
|
.map_err(|err| format!("移除原始拖拽块失败:{err:?}"))?;
|
|||
|
|
|
|||
|
|
let mut insert_at = match placement {
|
|||
|
|
DropPlacement::Before => target,
|
|||
|
|
DropPlacement::After => target + 1,
|
|||
|
|
};
|
|||
|
|
if source < insert_at {
|
|||
|
|
insert_at = insert_at.saturating_sub(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let current_children = container.children();
|
|||
|
|
let current_len = current_children.length() as usize;
|
|||
|
|
if insert_at >= current_len {
|
|||
|
|
container
|
|||
|
|
.append_child(&moving)
|
|||
|
|
.map_err(|err| format!("追加拖拽块失败:{err:?}"))?;
|
|||
|
|
} else {
|
|||
|
|
let next_sibling = current_children
|
|||
|
|
.item(insert_at as u32)
|
|||
|
|
.ok_or_else(|| format!("找不到第 {insert_at} 个 HTML 插入位置"))?;
|
|||
|
|
container
|
|||
|
|
.insert_before(&moving, Some(&next_sibling))
|
|||
|
|
.map_err(|err| format!("插入拖拽块失败:{err:?}"))?;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Ok(container.inner_html())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn p0_extensions() -> Vec<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>);
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
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.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(
|
|||
|
|
¤t_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
|
|||
|
|
.closest(HANDLE_SHELL_SELECTOR)
|
|||
|
|
.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(), ¤t_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(
|
|||
|
|
¤t_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 || {
|
|||
|
|
if let Some(anchor) = floating_toolbar_anchor.get() {
|
|||
|
|
let toolbar_locked = toolbar_overlay_locked(
|
|||
|
|
turn_into_open.get(),
|
|||
|
|
color_menu_open.get(),
|
|||
|
|
more_menu_open.get(),
|
|||
|
|
);
|
|||
|
|
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| {
|
|||
|
|
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 |_| {
|
|||
|
|
set_turn_into_open.update(|open| *open = !*open);
|
|||
|
|
set_block_menu_open.set(false);
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
"Text"
|
|||
|
|
</button>
|
|||
|
|
{move || {
|
|||
|
|
if turn_into_open.get() {
|
|||
|
|
view! {
|
|||
|
|
<div
|
|||
|
|
class="turn-into-panel"
|
|||
|
|
data-testid="turn-into-panel"
|
|||
|
|
on:mousedown=move |event: MouseEvent| {
|
|||
|
|
event.prevent_default();
|
|||
|
|
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>
|
|||
|
|
}.into_any()
|
|||
|
|
} else {
|
|||
|
|
().into_any()
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
</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 |_| {
|
|||
|
|
set_color_menu_open.update(|open| *open = !*open);
|
|||
|
|
set_more_menu_open.set(false);
|
|||
|
|
set_turn_into_open.set(false);
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
"Color"
|
|||
|
|
</button>
|
|||
|
|
{move || {
|
|||
|
|
if color_menu_open.get() {
|
|||
|
|
view! {
|
|||
|
|
<div
|
|||
|
|
class="toolbar-popover-panel"
|
|||
|
|
data-testid="toolbar-color-panel"
|
|||
|
|
on:mousedown=move |event: MouseEvent| {
|
|||
|
|
event.prevent_default();
|
|||
|
|
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>
|
|||
|
|
}.into_any()
|
|||
|
|
} else {
|
|||
|
|
().into_any()
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
</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 |_| {
|
|||
|
|
set_more_menu_open.update(|open| *open = !*open);
|
|||
|
|
set_color_menu_open.set(false);
|
|||
|
|
set_turn_into_open.set(false);
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
"More"
|
|||
|
|
</button>
|
|||
|
|
{move || {
|
|||
|
|
if more_menu_open.get() {
|
|||
|
|
view! {
|
|||
|
|
<div
|
|||
|
|
class="toolbar-popover-panel"
|
|||
|
|
data-testid="toolbar-more-panel"
|
|||
|
|
on:mousedown=move |event: MouseEvent| {
|
|||
|
|
event.prevent_default();
|
|||
|
|
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>
|
|||
|
|
}.into_any()
|
|||
|
|
} else {
|
|||
|
|
().into_any()
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
</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(¤t_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(¤t_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(¤t_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/> }
|
|||
|
|
});
|
|||
|
|
}
|