6497 lines
260 KiB
Rust
6497 lines
260 KiB
Rust
use leptos::ev;
|
||
use leptos::mount::{mount_to, mount_to_body, UnmountHandle};
|
||
use leptos::prelude::*;
|
||
use leptos_dom::helpers::window_event_listener;
|
||
use leptos_tiptap::{
|
||
TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditor,
|
||
TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel, TiptapHighlightAttributes,
|
||
TiptapInsertContentOptions, TiptapLinkResource, TiptapMarkName, TiptapRange,
|
||
TiptapSelectionState, TiptapTextAlign,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{json, Map, Value};
|
||
use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display};
|
||
use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
|
||
use web_sys::{
|
||
window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement,
|
||
MouseEvent, Node, Storage, WheelEvent,
|
||
};
|
||
|
||
const EDITOR_STAGE_SELECTOR: &str = "#editor-stage";
|
||
const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror";
|
||
const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell";
|
||
const SPIKE_STORAGE_KEY: &str = "mnote.leptos-tiptap-spike.document";
|
||
const HANDLE_MULTILINE_HEIGHT: f64 = 40.0;
|
||
const CONTENT_COLUMN_MAX_WIDTH: f64 = 708.0;
|
||
const CONTENT_COLUMN_HORIZONTAL_PADDING: f64 = 48.0;
|
||
const HANDLE_STAGE_PADDING_LEFT: f64 = 18.0;
|
||
const HANDLE_TRIGGER_WIDTH: f64 = 22.0;
|
||
const HANDLE_TRIGGER_GAP: f64 = 4.0;
|
||
const HANDLE_MENU_GAP: f64 = 0.0;
|
||
const HANDLE_TEXT_ALIGN_OFFSET: f64 = 96.0;
|
||
const HANDLE_MENU_LEFT_LIMIT: f64 = -320.0;
|
||
const RUNTIME_NAME: &str = "8123-leptos-tiptap-runtime";
|
||
const RUNTIME_VERSION: &str = "1.1.0";
|
||
const PROTOCOL: &str = "mnote.leptos_tiptap.bridge.v1";
|
||
const EVENT_PREFIX: &str = "mnote:leptos-tiptap-spike";
|
||
const READY_EVENT: &str = "mnote:leptos-tiptap-spike:ready";
|
||
const CHANGE_EVENT: &str = "mnote:leptos-tiptap-spike:change";
|
||
const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state";
|
||
const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status";
|
||
const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection";
|
||
const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command";
|
||
const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height";
|
||
const STANDALONE_ROOT_ID: &str = "mnote-leptos-tiptap-standalone-root";
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct ToolbarColorOption {
|
||
id: &'static str,
|
||
label: &'static str,
|
||
value: &'static str,
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct ToolbarAlignOption {
|
||
id: &'static str,
|
||
label: &'static str,
|
||
alignment: TiptapTextAlign,
|
||
}
|
||
|
||
const TOOLBAR_TEXT_COLORS: [ToolbarColorOption; 10] = [
|
||
ToolbarColorOption {
|
||
id: "default",
|
||
label: "默认文字",
|
||
value: "#111827",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "gray",
|
||
label: "灰色",
|
||
value: "#6b7280",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "brown",
|
||
label: "棕色",
|
||
value: "#8b5e3c",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "orange",
|
||
label: "橙色",
|
||
value: "#c96b1f",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "yellow",
|
||
label: "黄色",
|
||
value: "#b7791f",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "green",
|
||
label: "绿色",
|
||
value: "#18794e",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "blue",
|
||
label: "蓝色",
|
||
value: "#1d4ed8",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "purple",
|
||
label: "紫色",
|
||
value: "#7c3aed",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "pink",
|
||
label: "粉色",
|
||
value: "#be185d",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "red",
|
||
label: "红色",
|
||
value: "#b91c1c",
|
||
},
|
||
];
|
||
|
||
const TOOLBAR_HIGHLIGHT_COLORS: [ToolbarColorOption; 8] = [
|
||
ToolbarColorOption {
|
||
id: "yellow",
|
||
label: "黄色背景",
|
||
value: "#fef3c7",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "green",
|
||
label: "绿色背景",
|
||
value: "#dcfce7",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "blue",
|
||
label: "蓝色背景",
|
||
value: "#dbeafe",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "purple",
|
||
label: "紫色背景",
|
||
value: "#ede9fe",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "pink",
|
||
label: "粉色背景",
|
||
value: "#fce7f3",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "red",
|
||
label: "红色背景",
|
||
value: "#fee2e2",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "orange",
|
||
label: "橙色背景",
|
||
value: "#ffedd5",
|
||
},
|
||
ToolbarColorOption {
|
||
id: "gray",
|
||
label: "灰色背景",
|
||
value: "#e5e7eb",
|
||
},
|
||
];
|
||
|
||
const TOOLBAR_ALIGN_OPTIONS: [ToolbarAlignOption; 4] = [
|
||
ToolbarAlignOption {
|
||
id: "left",
|
||
label: "左对齐",
|
||
alignment: TiptapTextAlign::Left,
|
||
},
|
||
ToolbarAlignOption {
|
||
id: "center",
|
||
label: "居中",
|
||
alignment: TiptapTextAlign::Center,
|
||
},
|
||
ToolbarAlignOption {
|
||
id: "right",
|
||
label: "右对齐",
|
||
alignment: TiptapTextAlign::Right,
|
||
},
|
||
ToolbarAlignOption {
|
||
id: "justify",
|
||
label: "两端对齐",
|
||
alignment: TiptapTextAlign::Justify,
|
||
},
|
||
];
|
||
|
||
const SPIKE_STYLE: &str = r#"
|
||
:root {
|
||
--tt-color-text: #111827;
|
||
--tt-color-text-gray: #6b7280;
|
||
--tt-color-text-brown: #8b5e3c;
|
||
--tt-color-text-orange: #c96b1f;
|
||
--tt-color-text-yellow: #b7791f;
|
||
--tt-color-text-green: #18794e;
|
||
--tt-color-text-blue: #1d4ed8;
|
||
--tt-color-text-purple: #7c3aed;
|
||
--tt-color-text-pink: #be185d;
|
||
--tt-color-text-red: #b91c1c;
|
||
--tt-color-highlight-yellow: #fef3c7;
|
||
--tt-color-highlight-green: #dcfce7;
|
||
--tt-color-highlight-blue: #dbeafe;
|
||
--tt-color-highlight-purple: #ede9fe;
|
||
--tt-color-highlight-pink: #fce7f3;
|
||
--tt-color-highlight-red: #fee2e2;
|
||
--tt-color-highlight-orange: #ffedd5;
|
||
--tt-color-highlight-gray: #e5e7eb;
|
||
}
|
||
|
||
.app-shell {
|
||
max-width: 1180px;
|
||
margin: 0 auto;
|
||
padding: 28px 20px 72px;
|
||
}
|
||
|
||
.app-shell-embedded {
|
||
max-width: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.app-shell-embedded .hero-bar,
|
||
.app-shell-embedded .title-input,
|
||
.app-shell-embedded .editor-subcopy,
|
||
.app-shell-embedded .footer-strip,
|
||
.app-shell-embedded .debug-drawer,
|
||
.app-shell-embedded .editor-crumbs:first-child,
|
||
.app-shell-embedded .editor-crumbs:last-child {
|
||
display: none;
|
||
}
|
||
|
||
.app-shell-embedded .editor-card {
|
||
border: none;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
background: transparent;
|
||
overflow: visible;
|
||
}
|
||
|
||
.app-shell-embedded .editor-topbar {
|
||
display: none;
|
||
}
|
||
|
||
.app-shell-embedded .editor-stage {
|
||
margin: 0;
|
||
border: none;
|
||
border-radius: 0;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.hero-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 18px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.hero-meta {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.hero-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: fit-content;
|
||
padding: 6px 12px;
|
||
border-radius: 999px;
|
||
background: rgba(15, 118, 110, 0.12);
|
||
color: var(--accent-strong);
|
||
font-size: 12px;
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.hero-title {
|
||
margin: 0;
|
||
font-size: 40px;
|
||
line-height: 1.04;
|
||
}
|
||
|
||
.hero-copy {
|
||
margin: 0;
|
||
max-width: 680px;
|
||
color: var(--muted);
|
||
line-height: 1.75;
|
||
}
|
||
|
||
.hero-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.hero-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 16px;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
color: var(--ink);
|
||
font-size: 13px;
|
||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
|
||
}
|
||
|
||
.hero-chip strong {
|
||
color: var(--accent-strong);
|
||
}
|
||
|
||
.editor-card {
|
||
position: relative;
|
||
border: 1px solid rgba(100, 116, 139, 0.18);
|
||
border-radius: 30px;
|
||
background:
|
||
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(254, 251, 246, 0.98) 100%);
|
||
box-shadow: 0 28px 64px rgba(15, 23, 42, 0.08);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.editor-topbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 18px 22px 12px;
|
||
}
|
||
|
||
.editor-crumbs {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.editor-crumbs strong {
|
||
color: var(--ink);
|
||
}
|
||
|
||
.title-input {
|
||
width: 100%;
|
||
border: none;
|
||
outline: none;
|
||
background: transparent;
|
||
font-size: 48px;
|
||
line-height: 1.05;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
padding: 8px 22px 6px;
|
||
}
|
||
|
||
.title-input::placeholder {
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.editor-subcopy {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 16px;
|
||
align-items: center;
|
||
padding: 0 22px 18px;
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.hint-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
background: rgba(15, 118, 110, 0.09);
|
||
color: var(--accent-strong);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.editor-stage {
|
||
position: relative;
|
||
width: min(100%, 900px);
|
||
margin: 0 auto 18px;
|
||
border: 1px solid rgba(100, 116, 139, 0.18);
|
||
border-radius: 26px;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.editor-stage[data-page-wide-layout="true"] {
|
||
width: min(100%, 1120px);
|
||
}
|
||
|
||
.block-handle-shell {
|
||
position: absolute;
|
||
left: 12px;
|
||
z-index: 80;
|
||
display: inline-flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 1px;
|
||
width: 26px;
|
||
padding: 0;
|
||
background: transparent;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.block-handle-shell[data-dragging="true"] {
|
||
pointer-events: none;
|
||
opacity: 1;
|
||
}
|
||
|
||
.block-handle-insert,
|
||
.block-handle-trigger {
|
||
position: relative;
|
||
width: 22px;
|
||
height: 20px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: none;
|
||
border-radius: 5px;
|
||
background: transparent;
|
||
color: #6b7280;
|
||
cursor: pointer;
|
||
pointer-events: auto;
|
||
transition: background .12s ease, border-color .12s ease, box-shadow .12s ease, color .12s ease, opacity .12s ease;
|
||
}
|
||
|
||
.block-handle-insert {
|
||
opacity: 0;
|
||
}
|
||
|
||
.block-handle-shell[data-menu-open="true"] .block-handle-insert {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.block-handle-trigger {
|
||
height: 22px;
|
||
border: 1px solid rgba(148, 163, 184, 0.42);
|
||
background: rgba(255, 255, 255, 0.96);
|
||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.1);
|
||
cursor: grab;
|
||
}
|
||
|
||
.block-handle-trigger:hover,
|
||
.block-handle-shell[data-keyboard-selected="true"] .block-handle-trigger,
|
||
.block-handle-shell[data-menu-open="true"] .block-handle-trigger {
|
||
border-color: rgba(107, 114, 128, 0.5);
|
||
background: #f8fafc;
|
||
color: #4b5563;
|
||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14);
|
||
}
|
||
|
||
.block-handle-trigger:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.block-handle-icon-dots {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 3px);
|
||
grid-auto-rows: 3px;
|
||
gap: 3px;
|
||
}
|
||
|
||
.block-handle-icon-dots span,
|
||
.block-handle-icon-lines span {
|
||
width: 3px;
|
||
height: 3px;
|
||
border-radius: 999px;
|
||
background: currentColor;
|
||
}
|
||
|
||
.block-handle-icon-lines {
|
||
display: grid;
|
||
gap: 3px;
|
||
}
|
||
|
||
.block-handle-icon-lines span {
|
||
display: none;
|
||
width: 12px;
|
||
height: 1.5px;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.block-handle-shell[data-menu-open="true"] .block-handle-icon-dots span {
|
||
display: none;
|
||
}
|
||
|
||
.block-handle-shell[data-menu-open="true"] .block-handle-icon-lines span {
|
||
display: block;
|
||
}
|
||
|
||
.block-insert-line {
|
||
display: none;
|
||
}
|
||
|
||
.block-insert-plus {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 18px;
|
||
height: 18px;
|
||
border: 1px solid rgba(148, 163, 184, 0.42);
|
||
border-radius: 5px;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
color: #5f6773;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.1);
|
||
}
|
||
|
||
.block-handle-insert:hover,
|
||
.block-handle-insert:focus-visible {
|
||
opacity: 1;
|
||
}
|
||
|
||
.block-handle-insert:hover .block-insert-plus,
|
||
.block-handle-insert:focus-visible .block-insert-plus {
|
||
border-color: rgba(107, 114, 128, 0.5);
|
||
background: #f8fafc;
|
||
color: #4b5563;
|
||
}
|
||
|
||
.block-insert-tooltip {
|
||
position: absolute;
|
||
left: 26px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
min-width: 118px;
|
||
display: none;
|
||
gap: 5px;
|
||
align-items: center;
|
||
padding: 5px 7px;
|
||
border-radius: 5px;
|
||
background: rgba(17, 24, 39, 0.94);
|
||
color: #fff;
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
white-space: nowrap;
|
||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.2);
|
||
}
|
||
|
||
.block-insert-tooltip kbd {
|
||
color: rgba(255, 255, 255, 0.72);
|
||
font: inherit;
|
||
}
|
||
|
||
.block-handle-insert:hover .block-insert-tooltip,
|
||
.block-handle-insert:focus-visible .block-insert-tooltip {
|
||
display: inline-flex;
|
||
}
|
||
|
||
.block-drag-menu {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 242px;
|
||
min-width: 242px;
|
||
box-sizing: border-box;
|
||
display: grid;
|
||
gap: 0;
|
||
padding: 10px;
|
||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
background-image: linear-gradient(#fff, #fff);
|
||
color: var(--ink);
|
||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18);
|
||
z-index: 120;
|
||
pointer-events: auto;
|
||
isolation: isolate;
|
||
overflow: visible;
|
||
overscroll-behavior: contain;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
|
||
.block-drag-menu::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: -1;
|
||
border-radius: inherit;
|
||
background: #fff;
|
||
}
|
||
|
||
.block-drag-menu > * {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
|
||
.block-drag-menu-label,
|
||
.block-drag-menu-actions,
|
||
.block-drag-menu-section,
|
||
.block-drag-menu-item {
|
||
background-color: #fff;
|
||
}
|
||
|
||
.block-drag-command-input {
|
||
width: 100%;
|
||
height: 34px;
|
||
padding: 0 10px;
|
||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||
border-radius: 5px;
|
||
background: #fff;
|
||
color: #9ca3af;
|
||
font: inherit;
|
||
}
|
||
|
||
.block-drag-menu-label {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.block-drag-menu-label strong {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.block-drag-menu-label span {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.block-drag-menu-shortcut,
|
||
.block-drag-menu-arrow {
|
||
margin-left: auto;
|
||
color: #a1a1aa;
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.block-drag-menu-icon {
|
||
min-width: 22px;
|
||
color: #60646c;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
text-align: center;
|
||
}
|
||
|
||
.block-drag-menu-switch {
|
||
margin-left: auto;
|
||
width: 28px;
|
||
height: 16px;
|
||
border-radius: 999px;
|
||
background: #d4d4d8;
|
||
position: relative;
|
||
}
|
||
|
||
.block-drag-menu-switch::after {
|
||
content: "";
|
||
position: absolute;
|
||
width: 12px;
|
||
height: 12px;
|
||
left: 2px;
|
||
top: 2px;
|
||
border-radius: 999px;
|
||
background: #fff;
|
||
}
|
||
|
||
.block-drag-submenu {
|
||
position: absolute;
|
||
left: calc(100% + 8px);
|
||
top: 0;
|
||
width: 206px;
|
||
max-height: min(620px, 80vh);
|
||
overflow-y: auto;
|
||
display: grid;
|
||
gap: 0;
|
||
padding: 6px;
|
||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18);
|
||
z-index: 130;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.block-drag-menu-item[disabled] {
|
||
color: #a1a1aa;
|
||
cursor: default;
|
||
}
|
||
|
||
.block-drag-menu-item[disabled]:hover {
|
||
background: transparent;
|
||
}
|
||
|
||
.block-drag-menu-back {
|
||
width: 100%;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
padding: 2px 2px 6px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.block-drag-menu-back:hover {
|
||
color: var(--accent-strong);
|
||
}
|
||
|
||
.block-drag-menu-actions {
|
||
display: grid;
|
||
gap: 0;
|
||
}
|
||
|
||
.block-drag-menu-section {
|
||
display: grid;
|
||
gap: 0;
|
||
}
|
||
|
||
.block-drag-menu-divider {
|
||
height: 1px;
|
||
margin: 4px -10px;
|
||
background: rgba(148, 163, 184, 0.22);
|
||
}
|
||
|
||
.block-drag-menu-item {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
gap: 10px;
|
||
text-align: left;
|
||
border: 1px solid transparent;
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: inherit;
|
||
min-height: 31px;
|
||
padding: 5px 8px;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
transition: background .12s ease, border-color .12s ease;
|
||
}
|
||
|
||
.block-drag-menu-item:hover {
|
||
background: rgba(17, 24, 39, 0.06);
|
||
border-color: transparent;
|
||
}
|
||
|
||
.block-drag-menu-item > span:not(.block-drag-menu-icon):not(.block-drag-menu-shortcut):not(.block-drag-menu-arrow):not(.block-drag-menu-switch) {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.block-drag-menu-footer {
|
||
display: grid;
|
||
gap: 2px;
|
||
margin: 6px -2px -2px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||
color: #a1a1aa;
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
|
||
.block-drop-indicator {
|
||
position: absolute;
|
||
left: 56px;
|
||
right: 34px;
|
||
height: 2px;
|
||
border-radius: 999px;
|
||
background: rgba(15, 118, 110, 0.78);
|
||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||
z-index: 13;
|
||
}
|
||
|
||
.floating-toolbar {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
transform: translate(-50%, calc(-100% - 14px));
|
||
z-index: 12;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
flex-wrap: nowrap;
|
||
max-width: min(720px, calc(100% - 32px));
|
||
padding: 3px;
|
||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
color: #0f172a;
|
||
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.14);
|
||
backdrop-filter: blur(16px);
|
||
}
|
||
|
||
.floating-toolbar-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
.toolbar-popover-anchor {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.toolbar-separator {
|
||
width: 1px;
|
||
align-self: stretch;
|
||
margin: 4px 3px;
|
||
background: rgba(148, 163, 184, 0.28);
|
||
}
|
||
|
||
.toolbar-btn {
|
||
min-width: 30px;
|
||
height: 30px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: inherit;
|
||
padding: 0 10px;
|
||
cursor: pointer;
|
||
transition: background .12s ease, border-color .12s ease, transform .12s ease;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.toolbar-btn:hover {
|
||
background: rgba(148, 163, 184, 0.16);
|
||
}
|
||
|
||
.toolbar-btn[data-active="true"] {
|
||
background: rgba(15, 118, 110, 0.14);
|
||
color: var(--accent-strong);
|
||
}
|
||
|
||
.toolbar-btn[data-wide="true"] {
|
||
min-width: 56px;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.turn-into-panel {
|
||
position: absolute;
|
||
bottom: calc(100% + 10px);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
min-width: 220px;
|
||
max-height: min(420px, calc(100vh - 32px));
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 10px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||
background: rgba(255, 255, 255, 0.98);
|
||
color: var(--ink);
|
||
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.16);
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
z-index: 2;
|
||
}
|
||
|
||
.toolbar-popover-panel {
|
||
position: absolute;
|
||
bottom: calc(100% + 10px);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
min-width: 248px;
|
||
max-height: min(440px, calc(100vh - 32px));
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 12px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||
background: rgba(255, 255, 255, 0.98);
|
||
color: var(--ink);
|
||
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.16);
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
scrollbar-width: thin;
|
||
z-index: 2;
|
||
}
|
||
|
||
.turn-into-item,
|
||
.slash-item,
|
||
.toolbar-menu-item {
|
||
width: 100%;
|
||
display: grid;
|
||
gap: 4px;
|
||
text-align: left;
|
||
border: 1px solid transparent;
|
||
border-radius: 14px;
|
||
background: transparent;
|
||
color: inherit;
|
||
padding: 10px 12px;
|
||
cursor: pointer;
|
||
transition: background .12s ease, border-color .12s ease;
|
||
}
|
||
|
||
.turn-into-item:hover,
|
||
.slash-item:hover,
|
||
.slash-item[data-active="true"],
|
||
.toolbar-menu-item:hover,
|
||
.toolbar-menu-item[data-active="true"] {
|
||
background: rgba(15, 118, 110, 0.08);
|
||
border-color: rgba(15, 118, 110, 0.18);
|
||
}
|
||
|
||
.turn-into-item > span,
|
||
.slash-item-copy > span,
|
||
.toolbar-menu-item > span {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.slash-item-copy strong {
|
||
color: #30343b;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.toolbar-popover-section {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toolbar-popover-title {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.toolbar-color-grid {
|
||
display: grid;
|
||
gap: 6px;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.toolbar-color-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.toolbar-color-chip {
|
||
width: 18px;
|
||
height: 18px;
|
||
flex: 0 0 18px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(15, 23, 42, 0.14);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||
}
|
||
|
||
.toolbar-more-list {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.toolbar-more-item strong {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.slash-menu {
|
||
position: absolute;
|
||
top: 24px;
|
||
left: 24px;
|
||
z-index: 11;
|
||
width: min(316px, calc(100% - 48px));
|
||
max-height: min(430px, calc(100vh - 180px));
|
||
display: grid;
|
||
gap: 0;
|
||
padding: 8px;
|
||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18);
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.slash-list {
|
||
display: grid;
|
||
gap: 2px;
|
||
}
|
||
|
||
.slash-section-label {
|
||
padding: 10px 6px 5px;
|
||
color: #9097a3;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.slash-item-row {
|
||
display: grid;
|
||
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.slash-item-icon {
|
||
color: #6b7280;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.slash-item-copy {
|
||
display: grid;
|
||
gap: 1px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.slash-item-shortcut {
|
||
color: #a0a7b2;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.editor-surface {
|
||
display: block;
|
||
width: 100%;
|
||
min-height: 580px;
|
||
padding: 76px 0 32px;
|
||
}
|
||
|
||
.app-shell-embedded .editor-surface {
|
||
min-height: 640px;
|
||
padding: 18px 0 28vh;
|
||
}
|
||
|
||
.editor-surface .ProseMirror {
|
||
outline: none;
|
||
min-height: 472px;
|
||
line-height: 1.78;
|
||
font-size: 17px;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.app-shell-embedded .editor-surface .ProseMirror {
|
||
min-height: 600px;
|
||
width: min(100%, 708px);
|
||
margin: 0 auto;
|
||
padding: 2.25rem 3rem 30vh;
|
||
font-family: "DM Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||
}
|
||
|
||
.app-shell-embedded .editor-surface[data-small-text="true"] .ProseMirror,
|
||
.editor-surface[data-small-text="true"] .ProseMirror {
|
||
font-size: 15px;
|
||
line-height: 1.66;
|
||
}
|
||
|
||
.app-shell-embedded .editor-surface[data-layout-density="compact"] .ProseMirror,
|
||
.editor-surface[data-layout-density="compact"] .ProseMirror {
|
||
line-height: 1.54;
|
||
}
|
||
|
||
.app-shell-embedded .editor-surface[data-layout-density="spacious"] .ProseMirror,
|
||
.editor-surface[data-layout-density="spacious"] .ProseMirror {
|
||
line-height: 1.92;
|
||
}
|
||
|
||
.editor-surface .ProseMirror h1,
|
||
.editor-surface .ProseMirror h2,
|
||
.editor-surface .ProseMirror h3,
|
||
.editor-surface .ProseMirror h4 {
|
||
line-height: 1.12;
|
||
color: #0f172a;
|
||
margin: 1.12em 0 0.46em;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.editor-surface .ProseMirror h1 {
|
||
font-size: 2.1em;
|
||
}
|
||
|
||
.editor-surface .ProseMirror h2 {
|
||
font-size: 1.56em;
|
||
}
|
||
|
||
.editor-surface .ProseMirror h3 {
|
||
font-size: 1.28em;
|
||
}
|
||
|
||
.editor-surface .ProseMirror h4 {
|
||
font-size: 1.08em;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul,
|
||
.editor-surface .ProseMirror ol {
|
||
padding-left: 1.5em;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] {
|
||
list-style: none;
|
||
padding-left: 0.25em;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li:not(:has(> p:first-child)) {
|
||
list-style-type: none;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p,
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p span {
|
||
opacity: 0.5;
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li label {
|
||
position: relative;
|
||
padding-top: 0.375rem;
|
||
padding-right: 0.5rem;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"] {
|
||
position: absolute;
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li label span {
|
||
display: block;
|
||
width: 1em;
|
||
height: 1em;
|
||
border: 1px solid rgba(148, 163, 184, 0.7);
|
||
border-radius: 0.25rem;
|
||
position: relative;
|
||
cursor: pointer;
|
||
background-color: rgba(255, 255, 255, 0.96);
|
||
transition:
|
||
background-color 80ms ease-out,
|
||
border-color 80ms ease-out;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li label span::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 0.75em;
|
||
height: 0.75em;
|
||
background-color: #ffffff;
|
||
opacity: 0;
|
||
-webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") center/contain no-repeat;
|
||
mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") center/contain no-repeat;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"]:checked + span {
|
||
background: var(--accent-strong);
|
||
border-color: var(--accent-strong);
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"]:checked + span::before {
|
||
opacity: 1;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li > div {
|
||
flex: 1 1 0%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.editor-surface .ProseMirror ul[data-type="taskList"] li > div > p {
|
||
margin: 0;
|
||
}
|
||
|
||
.editor-surface .ProseMirror pre {
|
||
background: #14222b;
|
||
color: #f8fafc;
|
||
padding: 16px 18px;
|
||
border-radius: 18px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.editor-surface .ProseMirror blockquote {
|
||
margin: 1.1em 0;
|
||
padding-left: 16px;
|
||
border-left: 3px solid rgba(15, 118, 110, 0.5);
|
||
color: #334155;
|
||
}
|
||
|
||
.editor-surface .ProseMirror hr {
|
||
margin: 1.6em 0;
|
||
border: none;
|
||
border-top: 1px solid rgba(100, 116, 139, 0.28);
|
||
}
|
||
|
||
.editor-surface .ProseMirror p.is-editor-empty:first-child::before,
|
||
.editor-surface .ProseMirror p.is-empty::before {
|
||
color: #94a3b8;
|
||
content: attr(data-placeholder);
|
||
float: left;
|
||
height: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.editor-surface .ProseMirror p.is-editor-empty,
|
||
.editor-surface .ProseMirror p.is-empty {
|
||
min-height: 1.6em;
|
||
}
|
||
|
||
.editor-surface .ProseMirror p:has(> a.mnote-page-block-link) {
|
||
width: 100%;
|
||
margin: 0 0 8px;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
background: rgba(255, 71, 71, 0.12);
|
||
}
|
||
|
||
.editor-surface .ProseMirror a.mnote-page-block-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
max-width: 100%;
|
||
padding: 1px 4px;
|
||
border-radius: 4px;
|
||
color: #37352f;
|
||
font-weight: 600;
|
||
line-height: 1.45;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.editor-surface .ProseMirror a.mnote-page-block-link::before {
|
||
content: "";
|
||
width: 14px;
|
||
height: 14px;
|
||
flex: 0 0 auto;
|
||
border: 1.4px solid #c8c5c0;
|
||
border-radius: 3px;
|
||
background: linear-gradient(180deg, #ffffff 0%, #f6f4f1 100%);
|
||
box-shadow: inset 0 -2px 0 rgba(55, 53, 47, 0.08);
|
||
}
|
||
|
||
.editor-surface .ProseMirror a.mnote-page-block-link:hover {
|
||
color: #37352f;
|
||
background: rgba(55, 53, 47, 0.08);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.footer-strip {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
padding: 0 22px 22px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.footer-strip code {
|
||
color: var(--accent-strong);
|
||
}
|
||
|
||
.debug-drawer {
|
||
margin-top: 22px;
|
||
border: 1px solid rgba(100, 116, 139, 0.18);
|
||
border-radius: 22px;
|
||
background: rgba(255, 255, 255, 0.85);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.debug-drawer summary {
|
||
cursor: pointer;
|
||
list-style: none;
|
||
padding: 18px 20px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.debug-drawer summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
|
||
.debug-grid {
|
||
display: grid;
|
||
gap: 16px;
|
||
padding: 0 20px 20px;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
|
||
.debug-card {
|
||
border: 1px solid rgba(100, 116, 139, 0.18);
|
||
border-radius: 18px;
|
||
background: rgba(255, 255, 255, 0.88);
|
||
padding: 16px;
|
||
}
|
||
|
||
.debug-card h3 {
|
||
margin: 0 0 10px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.debug-card pre {
|
||
margin: 0;
|
||
max-height: 260px;
|
||
overflow: auto;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
color: #1e293b;
|
||
}
|
||
|
||
@media (max-width: 920px) {
|
||
.hero-bar,
|
||
.editor-topbar,
|
||
.footer-strip {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.title-input {
|
||
font-size: 34px;
|
||
}
|
||
|
||
.editor-surface {
|
||
padding: 88px 0 24px;
|
||
min-height: 460px;
|
||
}
|
||
|
||
.app-shell-embedded .editor-surface {
|
||
padding: 12px 0 24vh;
|
||
}
|
||
|
||
.app-shell-embedded .editor-surface .ProseMirror {
|
||
padding: 1.5rem 1.5rem 24vh;
|
||
}
|
||
|
||
.debug-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.floating-toolbar,
|
||
.slash-menu,
|
||
.block-drag-menu {
|
||
left: 16px;
|
||
right: 16px;
|
||
width: auto;
|
||
min-width: 0;
|
||
transform: none;
|
||
}
|
||
|
||
.floating-toolbar {
|
||
top: 12px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.editor-stage {
|
||
width: 100%;
|
||
}
|
||
|
||
.block-handle-shell,
|
||
.block-drop-indicator {
|
||
display: none;
|
||
}
|
||
|
||
.app-shell-embedded .editor-surface {
|
||
padding-left: 0;
|
||
}
|
||
}
|
||
"#;
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
enum SlashActionKind {
|
||
AiAssistant,
|
||
AiWrite,
|
||
ContinueWriting,
|
||
Summarize,
|
||
MoreAi,
|
||
Paragraph,
|
||
Heading1,
|
||
Heading2,
|
||
Heading3,
|
||
Heading4,
|
||
BulletList,
|
||
OrderedList,
|
||
Todo,
|
||
AdvancedTodo,
|
||
Quote,
|
||
CodeBlock,
|
||
Divider,
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct SlashAction {
|
||
kind: SlashActionKind,
|
||
id: &'static str,
|
||
category: &'static str,
|
||
icon: &'static str,
|
||
label: &'static str,
|
||
description: &'static str,
|
||
shortcut: &'static str,
|
||
}
|
||
|
||
const SLASH_ACTIONS: [SlashAction; 17] = [
|
||
SlashAction {
|
||
kind: SlashActionKind::AiAssistant,
|
||
id: "ai-assistant",
|
||
category: "AI 助理",
|
||
icon: "✦",
|
||
label: "AI 助理",
|
||
description: "按 Wolai 基线保留 AI 入口",
|
||
shortcut: "/ai",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::AiWrite,
|
||
id: "ai-write",
|
||
category: "AI 助理",
|
||
icon: "✎",
|
||
label: "用 AI 写作",
|
||
description: "唤起写作辅助入口",
|
||
shortcut: "/yaixz",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::ContinueWriting,
|
||
id: "continue-writing",
|
||
category: "AI 助理",
|
||
icon: "↪",
|
||
label: "续写",
|
||
description: "按当前上下文继续写作",
|
||
shortcut: "/xx",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::Summarize,
|
||
id: "summarize",
|
||
category: "AI 助理",
|
||
icon: "≡",
|
||
label: "总结",
|
||
description: "对当前内容生成摘要",
|
||
shortcut: "/zj",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::MoreAi,
|
||
id: "more-ai",
|
||
category: "AI 助理",
|
||
icon: "…",
|
||
label: "更多",
|
||
description: "更多 AI 命令",
|
||
shortcut: "›",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::Paragraph,
|
||
id: "paragraph",
|
||
category: "基础块列表",
|
||
icon: "Aa",
|
||
label: "文本",
|
||
description: "普通正文块",
|
||
shortcut: "/wb",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::Todo,
|
||
id: "todo",
|
||
category: "基础块列表",
|
||
icon: "☑",
|
||
label: "待办列表",
|
||
description: "创建可勾选任务",
|
||
shortcut: "/dblb",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::AdvancedTodo,
|
||
id: "advanced-todo",
|
||
category: "基础块列表",
|
||
icon: "☑",
|
||
label: "高级待办列表",
|
||
description: "保留 Wolai 高级待办入口",
|
||
shortcut: "/gjdblb",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::Heading1,
|
||
id: "heading-1",
|
||
category: "基础块列表",
|
||
icon: "H1",
|
||
label: "主标题",
|
||
description: "一级标题",
|
||
shortcut: "/h1",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::Heading2,
|
||
id: "heading-2",
|
||
category: "基础块列表",
|
||
icon: "H2",
|
||
label: "大标题",
|
||
description: "二级标题",
|
||
shortcut: "/h2",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::Heading3,
|
||
id: "heading-3",
|
||
category: "基础块列表",
|
||
icon: "H3",
|
||
label: "中标题",
|
||
description: "三级标题",
|
||
shortcut: "/h3",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::Heading4,
|
||
id: "heading-4",
|
||
category: "基础块列表",
|
||
icon: "H4",
|
||
label: "小标题",
|
||
description: "四级标题",
|
||
shortcut: "/h4",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::BulletList,
|
||
id: "bullet",
|
||
category: "基础块列表",
|
||
icon: "•",
|
||
label: "列表",
|
||
description: "记录普通要点",
|
||
shortcut: "/lb",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::OrderedList,
|
||
id: "ordered",
|
||
category: "基础块列表",
|
||
icon: "1.",
|
||
label: "数字列表",
|
||
description: "记录步骤顺序",
|
||
shortcut: "/szlb",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::Quote,
|
||
id: "quote",
|
||
category: "基础块列表",
|
||
icon: "❝",
|
||
label: "引述文字",
|
||
description: "包住注释与摘录",
|
||
shortcut: "/ys",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::CodeBlock,
|
||
id: "code-block",
|
||
category: "基础块列表",
|
||
icon: "<> ",
|
||
label: "代码片段",
|
||
description: "插入带语义的代码块",
|
||
shortcut: "/dm",
|
||
},
|
||
SlashAction {
|
||
kind: SlashActionKind::Divider,
|
||
id: "divider",
|
||
category: "基础块列表",
|
||
icon: "—",
|
||
label: "分割线",
|
||
description: "插入分隔线",
|
||
shortcut: "/fgx",
|
||
},
|
||
];
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct PersistedSpikeDocument {
|
||
#[serde(default)]
|
||
document_id: Option<String>,
|
||
#[serde(default)]
|
||
workspace_id: Option<String>,
|
||
title: String,
|
||
content: Value,
|
||
#[serde(default)]
|
||
html: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
struct PersistedDocumentIdentity {
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct BridgeEnvelope<T>
|
||
where
|
||
T: Serialize,
|
||
{
|
||
protocol: &'static str,
|
||
runtime: &'static str,
|
||
version: &'static str,
|
||
source: &'static str,
|
||
event: &'static str,
|
||
payload: T,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct BridgeSelectorsPayload {
|
||
root: &'static str,
|
||
stage: &'static str,
|
||
editor: &'static str,
|
||
toolbar: &'static str,
|
||
slash_menu: &'static str,
|
||
handle: &'static str,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct HostStatusPayload {
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: String,
|
||
dirty_count: u32,
|
||
selected_block_index: Option<usize>,
|
||
current_block_id: Option<String>,
|
||
editor_focused: bool,
|
||
read_only: bool,
|
||
slash_open: bool,
|
||
toolbar_open: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ReadyPayload {
|
||
runtime_name: &'static str,
|
||
selectors: BridgeSelectorsPayload,
|
||
supported_commands: Vec<&'static str>,
|
||
supports_embedded_mode: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct StatePayload {
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: String,
|
||
dirty_count: u32,
|
||
selected_block_index: Option<usize>,
|
||
editor_focused: bool,
|
||
slash_open: bool,
|
||
toolbar_open: bool,
|
||
read_only: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SelectionPayload {
|
||
selection: TiptapSelectionState,
|
||
summary: String,
|
||
editor_focused: bool,
|
||
current_block_index: Option<usize>,
|
||
current_block_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ChangeMetaPayload {
|
||
dirty_count: u32,
|
||
editor_focused: bool,
|
||
slash_open: bool,
|
||
toolbar_open: bool,
|
||
selected_block_index: Option<usize>,
|
||
revision: Option<i64>,
|
||
conflict_detection_key: Option<String>,
|
||
read_only: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ChangePayload {
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: String,
|
||
content: Value,
|
||
meta: ChangeMetaPayload,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct HeightPayload {
|
||
height: f64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct HostCommandEnvelope {
|
||
protocol: Option<String>,
|
||
runtime: Option<String>,
|
||
version: Option<String>,
|
||
source: Option<String>,
|
||
event: Option<String>,
|
||
payload: Option<HostCommandPayload>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct RuntimePageOptions {
|
||
wide_layout: Option<bool>,
|
||
small_text: Option<bool>,
|
||
layout_density: Option<String>,
|
||
show_heading_numbers: Option<bool>,
|
||
embed_default_block_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct HostCommandPayload {
|
||
command: Option<String>,
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: Option<String>,
|
||
content: Option<Value>,
|
||
editable: Option<bool>,
|
||
page_options: Option<RuntimePageOptions>,
|
||
block_id: Option<String>,
|
||
block_index: Option<usize>,
|
||
text: Option<String>,
|
||
reference_document_id: Option<String>,
|
||
reference_block_id: Option<String>,
|
||
current_block_id: Option<String>,
|
||
selection: Option<Value>,
|
||
revision: Option<i64>,
|
||
conflict_detection_key: Option<String>,
|
||
read_only: Option<bool>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LegacyHostEnvelope {
|
||
protocol: Option<String>,
|
||
runtime: Option<String>,
|
||
version: Option<String>,
|
||
source: Option<String>,
|
||
event: Option<String>,
|
||
payload: Option<HostDocumentPayload>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct HostDocumentPayload {
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: Option<String>,
|
||
content: Option<Value>,
|
||
revision: Option<i64>,
|
||
conflict_detection_key: Option<String>,
|
||
read_only: Option<bool>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum HostCommandKind {
|
||
Undo,
|
||
Redo,
|
||
ReplaceContent,
|
||
SetPageOptions,
|
||
InsertInlineReference,
|
||
InsertEmbedReference,
|
||
RequestCurrentBlockId,
|
||
SetEditable,
|
||
Focus,
|
||
Bootstrap,
|
||
}
|
||
|
||
impl HostCommandKind {
|
||
fn from_event_name(event_name: &str) -> Option<Self> {
|
||
match event_name {
|
||
"undo" => Some(Self::Undo),
|
||
"redo" => Some(Self::Redo),
|
||
"replaceContent" | "replace-document" => Some(Self::ReplaceContent),
|
||
"setPageOptions" | "set-page-options" => Some(Self::SetPageOptions),
|
||
"insertInlineReference" => Some(Self::InsertInlineReference),
|
||
"insertEmbedReference" => Some(Self::InsertEmbedReference),
|
||
"requestCurrentBlockId" => Some(Self::RequestCurrentBlockId),
|
||
"setEditable" | "set-editable" => Some(Self::SetEditable),
|
||
"focus" => Some(Self::Focus),
|
||
"bootstrap" => Some(Self::Bootstrap),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn from_payload(payload: &HostCommandPayload) -> Option<Self> {
|
||
payload.command.as_deref().and_then(Self::from_event_name)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
struct CurrentBlockInfo {
|
||
index: Option<usize>,
|
||
block_id: Option<String>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn parses_command_aliases_and_canonical_names() {
|
||
assert_eq!(
|
||
HostCommandKind::from_event_name("replace-document"),
|
||
Some(HostCommandKind::ReplaceContent)
|
||
);
|
||
assert_eq!(
|
||
HostCommandKind::from_event_name("replaceContent"),
|
||
Some(HostCommandKind::ReplaceContent)
|
||
);
|
||
assert_eq!(
|
||
HostCommandKind::from_event_name("requestCurrentBlockId"),
|
||
Some(HostCommandKind::RequestCurrentBlockId)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reads_command_from_payload_field() {
|
||
let payload = HostCommandPayload {
|
||
command: Some("setEditable".to_string()),
|
||
document_id: None,
|
||
workspace_id: None,
|
||
title: None,
|
||
content: None,
|
||
editable: Some(true),
|
||
page_options: None,
|
||
block_id: None,
|
||
block_index: None,
|
||
text: None,
|
||
reference_document_id: None,
|
||
reference_block_id: None,
|
||
current_block_id: None,
|
||
selection: None,
|
||
revision: None,
|
||
conflict_detection_key: None,
|
||
read_only: None,
|
||
};
|
||
|
||
assert_eq!(
|
||
HostCommandKind::from_payload(&payload),
|
||
Some(HostCommandKind::SetEditable)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resolves_embedded_mode_from_explicit_runtime_mode_first() {
|
||
assert_eq!(
|
||
resolve_runtime_delivery_mode(Some(RuntimeDeliveryMode::Embedded), false),
|
||
RuntimeDeliveryMode::Embedded
|
||
);
|
||
assert_eq!(
|
||
resolve_runtime_delivery_mode(Some(RuntimeDeliveryMode::Standalone), true),
|
||
RuntimeDeliveryMode::Standalone
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resolves_embedded_mode_from_url_when_runtime_mode_absent() {
|
||
assert_eq!(
|
||
resolve_runtime_delivery_mode(None, true),
|
||
RuntimeDeliveryMode::Embedded
|
||
);
|
||
assert_eq!(
|
||
resolve_runtime_delivery_mode(None, false),
|
||
RuntimeDeliveryMode::Standalone
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn persisted_document_storage_key_isolated_per_document_identity() {
|
||
let doc_a =
|
||
persisted_document_identity(Some("doc-a".to_string()), Some("ws-1".to_string()));
|
||
let doc_b =
|
||
persisted_document_identity(Some("doc-b".to_string()), Some("ws-1".to_string()));
|
||
let doc_without_workspace = persisted_document_identity(Some("doc-a".to_string()), None);
|
||
|
||
assert_eq!(
|
||
persisted_document_storage_key(&doc_a),
|
||
"mnote.leptos-tiptap-spike.document:ws-1:doc-a"
|
||
);
|
||
assert_eq!(
|
||
persisted_document_storage_key(&doc_b),
|
||
"mnote.leptos-tiptap-spike.document:ws-1:doc-b"
|
||
);
|
||
assert_eq!(
|
||
persisted_document_storage_key(&doc_without_workspace),
|
||
"mnote.leptos-tiptap-spike.document:doc-a"
|
||
);
|
||
assert_ne!(
|
||
persisted_document_storage_key(&doc_a),
|
||
persisted_document_storage_key(&doc_b)
|
||
);
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum RuntimeDeliveryMode {
|
||
Standalone,
|
||
Embedded,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct MountOptions {
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: Option<String>,
|
||
content: Option<Value>,
|
||
html: Option<String>,
|
||
editable: Option<bool>,
|
||
read_only: Option<bool>,
|
||
revision: Option<i64>,
|
||
conflict_detection_key: Option<String>,
|
||
page_options: Option<RuntimePageOptions>,
|
||
}
|
||
|
||
struct RuntimeMountContext {
|
||
id: u32,
|
||
target: EventTarget,
|
||
mode: RuntimeDeliveryMode,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct RuntimeMountOptions {
|
||
options: MountOptions,
|
||
mode: RuntimeDeliveryMode,
|
||
}
|
||
|
||
struct MountedRuntimeListener {
|
||
target: EventTarget,
|
||
listener: Closure<dyn FnMut(Event)>,
|
||
}
|
||
|
||
struct MountedRuntime {
|
||
listeners: Vec<MountedRuntimeListener>,
|
||
mount_handle: Box<dyn Any>,
|
||
}
|
||
|
||
impl Drop for MountedRuntime {
|
||
fn drop(&mut self) {
|
||
for listener in &self.listeners {
|
||
let _ = listener.target.remove_event_listener_with_callback(
|
||
COMMAND_EVENT,
|
||
listener.listener.as_ref().unchecked_ref(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
thread_local! {
|
||
static RUNTIME_MOUNT_CONTEXT: std::cell::RefCell<Option<RuntimeMountContext>> = const { std::cell::RefCell::new(None) };
|
||
static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell<Option<RuntimeMountOptions>> = const { std::cell::RefCell::new(None) };
|
||
static MOUNTED_HANDLES: std::cell::RefCell<HashMap<u32, MountedRuntime>> = std::cell::RefCell::new(HashMap::new());
|
||
static PENDING_RUNTIME_LISTENERS: std::cell::RefCell<HashMap<u32, Vec<MountedRuntimeListener>>> = std::cell::RefCell::new(HashMap::new());
|
||
static NEXT_MOUNT_ID: Cell<u32> = const { Cell::new(1) };
|
||
}
|
||
|
||
fn next_mount_id() -> u32 {
|
||
NEXT_MOUNT_ID.with(|cell| {
|
||
let next = cell.get();
|
||
cell.set(next.saturating_add(1).max(1));
|
||
next
|
||
})
|
||
}
|
||
|
||
fn set_runtime_mount_context(context: Option<RuntimeMountContext>) {
|
||
RUNTIME_MOUNT_CONTEXT.with(|cell| {
|
||
*cell.borrow_mut() = context;
|
||
});
|
||
}
|
||
|
||
fn runtime_mount_context() -> Option<(u32, EventTarget, RuntimeDeliveryMode)> {
|
||
RUNTIME_MOUNT_CONTEXT.with(|cell| {
|
||
cell.borrow()
|
||
.as_ref()
|
||
.map(|context| (context.id, context.target.clone(), context.mode))
|
||
})
|
||
}
|
||
|
||
fn runtime_delivery_mode() -> RuntimeDeliveryMode {
|
||
resolve_runtime_delivery_mode(
|
||
runtime_mount_context().map(|(_, _, mode)| mode),
|
||
url_requests_embedded_mode(),
|
||
)
|
||
}
|
||
|
||
fn set_runtime_mount_options(options: Option<RuntimeMountOptions>) {
|
||
RUNTIME_MOUNT_OPTIONS.with(|cell| {
|
||
*cell.borrow_mut() = options;
|
||
});
|
||
}
|
||
|
||
fn runtime_mount_options() -> Option<RuntimeMountOptions> {
|
||
RUNTIME_MOUNT_OPTIONS.with(|cell| cell.borrow().clone())
|
||
}
|
||
|
||
fn runtime_event_target() -> Option<EventTarget> {
|
||
if let Some((_, target, _)) = runtime_mount_context() {
|
||
return Some(target);
|
||
}
|
||
|
||
window()
|
||
.and_then(|win| win.document())
|
||
.and_then(|document| document.body())
|
||
.map(|body| body.into())
|
||
}
|
||
|
||
fn runtime_block_id_from_index(index: usize) -> Option<String> {
|
||
Some(format!("top-level-{index}"))
|
||
}
|
||
|
||
fn current_runtime_block_info() -> CurrentBlockInfo {
|
||
hovered_block_from_selection()
|
||
.and_then(|block| {
|
||
Some(CurrentBlockInfo {
|
||
index: Some(block.index),
|
||
block_id: runtime_block_id_from_index(block.index),
|
||
})
|
||
})
|
||
.unwrap_or(CurrentBlockInfo {
|
||
index: None,
|
||
block_id: None,
|
||
})
|
||
}
|
||
|
||
fn current_runtime_block_info_from_hovered(
|
||
hovered_block: Option<HoveredBlockState>,
|
||
) -> CurrentBlockInfo {
|
||
hovered_block
|
||
.map(|block| CurrentBlockInfo {
|
||
index: Some(block.index),
|
||
block_id: runtime_block_id_from_index(block.index),
|
||
})
|
||
.unwrap_or(CurrentBlockInfo {
|
||
index: None,
|
||
block_id: None,
|
||
})
|
||
}
|
||
|
||
fn dispatch_runtime_event<T>(event_name: &'static str, payload: &T)
|
||
where
|
||
T: Serialize,
|
||
{
|
||
let envelope = BridgeEnvelope {
|
||
protocol: PROTOCOL,
|
||
runtime: RUNTIME_NAME,
|
||
version: RUNTIME_VERSION,
|
||
source: EVENT_PREFIX,
|
||
event: event_name,
|
||
payload,
|
||
};
|
||
let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else {
|
||
return;
|
||
};
|
||
|
||
let Some(target) = runtime_event_target() else {
|
||
return;
|
||
};
|
||
|
||
let init = CustomEventInit::new();
|
||
init.set_detail(&detail_value);
|
||
if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) {
|
||
let _ = target.dispatch_event(&event);
|
||
}
|
||
}
|
||
|
||
fn dispatch_custom_event_to_target<T>(target: &EventTarget, event_name: &'static str, payload: &T)
|
||
where
|
||
T: Serialize,
|
||
{
|
||
let envelope = BridgeEnvelope {
|
||
protocol: PROTOCOL,
|
||
runtime: RUNTIME_NAME,
|
||
version: RUNTIME_VERSION,
|
||
source: EVENT_PREFIX,
|
||
event: event_name,
|
||
payload,
|
||
};
|
||
let Ok(detail_value) = serde_wasm_bindgen::to_value(&envelope) else {
|
||
return;
|
||
};
|
||
|
||
let init = CustomEventInit::new();
|
||
init.set_detail(&detail_value);
|
||
init.set_bubbles(true);
|
||
if let Ok(event) = CustomEvent::new_with_event_init_dict(event_name, &init) {
|
||
let _ = target.dispatch_event(&event);
|
||
}
|
||
}
|
||
|
||
fn dispatch_ready_event(payload: &ReadyPayload) {
|
||
dispatch_runtime_event(READY_EVENT, payload);
|
||
}
|
||
|
||
fn dispatch_change_event(payload: &ChangePayload) {
|
||
dispatch_runtime_event(CHANGE_EVENT, payload);
|
||
}
|
||
|
||
fn dispatch_state_event(payload: &StatePayload) {
|
||
dispatch_runtime_event(STATE_EVENT, payload);
|
||
}
|
||
|
||
fn dispatch_status_event(payload: &HostStatusPayload) {
|
||
dispatch_runtime_event(STATUS_EVENT, payload);
|
||
}
|
||
|
||
fn dispatch_selection_event(payload: &SelectionPayload) {
|
||
dispatch_runtime_event(SELECTION_EVENT, payload);
|
||
}
|
||
|
||
fn register_unmount_handle<M: Any + leptos::prelude::Mountable + 'static>(
|
||
id: u32,
|
||
target: EventTarget,
|
||
listener: Closure<dyn FnMut(Event)>,
|
||
handle: UnmountHandle<M>,
|
||
) {
|
||
let mut listeners = vec![MountedRuntimeListener { target, listener }];
|
||
PENDING_RUNTIME_LISTENERS.with(|registry| {
|
||
if let Some(mut pending) = registry.borrow_mut().remove(&id) {
|
||
listeners.append(&mut pending);
|
||
}
|
||
});
|
||
MOUNTED_HANDLES.with(|registry| {
|
||
registry.borrow_mut().insert(
|
||
id,
|
||
MountedRuntime {
|
||
listeners,
|
||
mount_handle: Box::new(handle),
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
fn register_runtime_listener(id: u32, target: EventTarget, listener: Closure<dyn FnMut(Event)>) {
|
||
MOUNTED_HANDLES.with(|registry| {
|
||
if let Some(runtime) = registry.borrow_mut().get_mut(&id) {
|
||
runtime
|
||
.listeners
|
||
.push(MountedRuntimeListener { target, listener });
|
||
} else {
|
||
PENDING_RUNTIME_LISTENERS.with(|pending_registry| {
|
||
pending_registry
|
||
.borrow_mut()
|
||
.entry(id)
|
||
.or_default()
|
||
.push(MountedRuntimeListener { target, listener });
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
fn take_unmount_handle(id: u32) -> Option<MountedRuntime> {
|
||
PENDING_RUNTIME_LISTENERS.with(|registry| {
|
||
registry.borrow_mut().remove(&id);
|
||
});
|
||
MOUNTED_HANDLES.with(|registry| registry.borrow_mut().remove(&id))
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
pub fn mount(container: Element, options: JsValue) -> Result<u32, JsValue> {
|
||
console_error_panic_hook::set_once();
|
||
let options = if options.is_undefined() || options.is_null() {
|
||
MountOptions::default()
|
||
} else {
|
||
serde_wasm_bindgen::from_value(options)?
|
||
};
|
||
let target = container
|
||
.dyn_into::<HtmlElement>()
|
||
.map_err(|_| JsValue::from_str("mount 目标必须是 HTML 元素"))?;
|
||
let mount_id = mount_app_into(target, options, RuntimeDeliveryMode::Embedded);
|
||
Ok(mount_id)
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
pub fn unmount(mount_id: u32) -> Result<(), JsValue> {
|
||
if let Some(handle) = take_unmount_handle(mount_id) {
|
||
if runtime_mount_context().map(|(id, _, _)| id) == Some(mount_id) {
|
||
set_runtime_mount_context(None);
|
||
set_runtime_mount_options(None);
|
||
}
|
||
drop(handle);
|
||
Ok(())
|
||
} else {
|
||
Err(JsValue::from_str("找不到对应的挂载句柄"))
|
||
}
|
||
}
|
||
|
||
fn mount_app_into(target: HtmlElement, options: MountOptions, mode: RuntimeDeliveryMode) -> u32 {
|
||
let mount_id = next_mount_id();
|
||
let context_target: EventTarget = target.clone().into();
|
||
let mount_options = RuntimeMountOptions {
|
||
options: options.clone(),
|
||
mode,
|
||
};
|
||
|
||
set_runtime_mount_options(Some(mount_options.clone()));
|
||
set_runtime_mount_context(Some(RuntimeMountContext {
|
||
id: mount_id,
|
||
target: context_target.clone(),
|
||
mode,
|
||
}));
|
||
|
||
let handle = mount_to(target, move || {
|
||
view! {
|
||
<App mount_options=options />
|
||
}
|
||
});
|
||
|
||
let command_listener = build_command_listener(mount_id);
|
||
let _ = context_target
|
||
.add_event_listener_with_callback(COMMAND_EVENT, command_listener.as_ref().unchecked_ref());
|
||
|
||
register_unmount_handle(mount_id, context_target, command_listener, handle);
|
||
mount_id
|
||
}
|
||
|
||
fn build_command_listener(mount_id: u32) -> Closure<dyn FnMut(Event)> {
|
||
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
|
||
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
|
||
return;
|
||
};
|
||
let detail = custom_event.detail();
|
||
let Ok(envelope) = serde_wasm_bindgen::from_value::<HostCommandEnvelope>(detail) else {
|
||
return;
|
||
};
|
||
if envelope.protocol.as_deref() != Some(PROTOCOL) {
|
||
return;
|
||
}
|
||
|
||
let Some(payload) = envelope.payload else {
|
||
return;
|
||
};
|
||
let Some(command_kind) = HostCommandKind::from_payload(&payload) else {
|
||
return;
|
||
};
|
||
|
||
let _ = mount_id;
|
||
let _ = command_kind;
|
||
}))
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct HoveredBlockState {
|
||
index: usize,
|
||
label: String,
|
||
top: f64,
|
||
height: f64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct FloatingToolbarAnchor {
|
||
top: f64,
|
||
left: f64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct BlockMenuLayout {
|
||
max_height: f64,
|
||
open_upward: bool,
|
||
top: f64,
|
||
left: f64,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum DropPlacement {
|
||
Before,
|
||
After,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct DropIndicatorState {
|
||
index: usize,
|
||
top: f64,
|
||
placement: DropPlacement,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct PendingDragState {
|
||
index: usize,
|
||
anchor: HoveredBlockState,
|
||
start_x: i32,
|
||
start_y: i32,
|
||
}
|
||
|
||
fn local_storage() -> Option<Storage> {
|
||
window().and_then(|win| win.local_storage().ok().flatten())
|
||
}
|
||
|
||
fn default_title() -> String {
|
||
"Leptos Tiptap 主编辑器 P0".to_string()
|
||
}
|
||
|
||
fn current_query_param(key: &str) -> Option<String> {
|
||
window()
|
||
.and_then(|win| win.location().search().ok())
|
||
.and_then(|search| {
|
||
let query = search.trim_start_matches('?');
|
||
query.split('&').find_map(|pair| {
|
||
let (candidate_key, candidate_value) = pair.split_once('=')?;
|
||
if candidate_key == key && !candidate_value.trim().is_empty() {
|
||
Some(candidate_value.trim().to_string())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
fn normalize_identity_value(value: Option<String>) -> Option<String> {
|
||
value.and_then(|value| {
|
||
let trimmed = value.trim();
|
||
if trimmed.is_empty() {
|
||
None
|
||
} else {
|
||
Some(trimmed.to_string())
|
||
}
|
||
})
|
||
}
|
||
|
||
fn persisted_document_identity(
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
) -> PersistedDocumentIdentity {
|
||
PersistedDocumentIdentity {
|
||
document_id: normalize_identity_value(document_id),
|
||
workspace_id: normalize_identity_value(workspace_id),
|
||
}
|
||
}
|
||
|
||
fn resolve_persisted_document_identity(
|
||
explicit_document_id: Option<String>,
|
||
explicit_workspace_id: Option<String>,
|
||
) -> PersistedDocumentIdentity {
|
||
persisted_document_identity(
|
||
explicit_document_id.or_else(|| current_query_param("documentId")),
|
||
explicit_workspace_id.or_else(|| current_query_param("workspaceId")),
|
||
)
|
||
}
|
||
|
||
fn persisted_document_storage_key(identity: &PersistedDocumentIdentity) -> String {
|
||
match (&identity.workspace_id, &identity.document_id) {
|
||
(Some(workspace_id), Some(document_id)) => {
|
||
format!("{SPIKE_STORAGE_KEY}:{workspace_id}:{document_id}")
|
||
}
|
||
(None, Some(document_id)) => format!("{SPIKE_STORAGE_KEY}:{document_id}"),
|
||
_ => SPIKE_STORAGE_KEY.to_string(),
|
||
}
|
||
}
|
||
|
||
fn runtime_persisted_identity(
|
||
document_id: ReadSignal<Option<String>>,
|
||
workspace_id: ReadSignal<Option<String>>,
|
||
) -> PersistedDocumentIdentity {
|
||
persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked())
|
||
}
|
||
|
||
fn persist_document_state(
|
||
identity: &PersistedDocumentIdentity,
|
||
title: &str,
|
||
content: &Value,
|
||
html: Option<String>,
|
||
) -> Result<(), String> {
|
||
let payload = PersistedSpikeDocument {
|
||
document_id: identity.document_id.clone(),
|
||
workspace_id: identity.workspace_id.clone(),
|
||
title: title.to_string(),
|
||
content: content.clone(),
|
||
html,
|
||
};
|
||
let raw = serde_json::to_string(&payload).map_err(|err| format!("序列化草稿失败:{err}"))?;
|
||
local_storage()
|
||
.ok_or_else(|| "浏览器 localStorage 不可用".to_string())?
|
||
.set_item(&persisted_document_storage_key(identity), &raw)
|
||
.map_err(|err| format!("写入 localStorage 失败:{err:?}"))
|
||
}
|
||
|
||
fn load_persisted_document(identity: &PersistedDocumentIdentity) -> Option<PersistedSpikeDocument> {
|
||
let raw = local_storage()?
|
||
.get_item(&persisted_document_storage_key(identity))
|
||
.ok()
|
||
.flatten()?;
|
||
let document = serde_json::from_str::<PersistedSpikeDocument>(&raw).ok()?;
|
||
let stored_identity =
|
||
persisted_document_identity(document.document_id.clone(), document.workspace_id.clone());
|
||
if stored_identity == *identity || identity.document_id.is_none() {
|
||
Some(document)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn url_requests_embedded_mode() -> bool {
|
||
window()
|
||
.and_then(|win| win.location().search().ok())
|
||
.map(|search| search.contains("embedded=1"))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
fn resolve_runtime_delivery_mode(
|
||
explicit_mode: Option<RuntimeDeliveryMode>,
|
||
url_embedded: bool,
|
||
) -> RuntimeDeliveryMode {
|
||
if let Some(mode) = explicit_mode {
|
||
return mode;
|
||
}
|
||
|
||
if url_embedded {
|
||
RuntimeDeliveryMode::Embedded
|
||
} else {
|
||
RuntimeDeliveryMode::Standalone
|
||
}
|
||
}
|
||
|
||
fn is_embedded_mode() -> bool {
|
||
runtime_delivery_mode() == RuntimeDeliveryMode::Embedded
|
||
}
|
||
|
||
fn current_document_id() -> Option<String> {
|
||
current_query_param("documentId")
|
||
}
|
||
|
||
fn current_workspace_id() -> Option<String> {
|
||
current_query_param("workspaceId")
|
||
}
|
||
|
||
fn selection_event_payload(
|
||
selection: &TiptapSelectionState,
|
||
editor_focused: bool,
|
||
hovered_block: Option<HoveredBlockState>,
|
||
) -> SelectionPayload {
|
||
SelectionPayload {
|
||
selection: selection.clone(),
|
||
summary: selection_summary(selection),
|
||
editor_focused,
|
||
current_block_index: hovered_block.as_ref().map(|block| block.index),
|
||
current_block_id: hovered_block.and_then(|block| runtime_block_id_from_index(block.index)),
|
||
}
|
||
}
|
||
|
||
fn runtime_state_payload(
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: String,
|
||
dirty_count: u32,
|
||
hovered_block: Option<HoveredBlockState>,
|
||
editor_focused: bool,
|
||
slash_open: bool,
|
||
turn_into_open: bool,
|
||
color_menu_open: bool,
|
||
more_menu_open: bool,
|
||
read_only: bool,
|
||
) -> StatePayload {
|
||
StatePayload {
|
||
document_id,
|
||
workspace_id,
|
||
title,
|
||
dirty_count,
|
||
selected_block_index: hovered_block.map(|block| block.index),
|
||
editor_focused,
|
||
slash_open,
|
||
toolbar_open: toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open),
|
||
read_only,
|
||
}
|
||
}
|
||
|
||
fn runtime_status_payload(
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: String,
|
||
dirty_count: u32,
|
||
hovered_block: Option<HoveredBlockState>,
|
||
editor_focused: bool,
|
||
slash_open: bool,
|
||
turn_into_open: bool,
|
||
color_menu_open: bool,
|
||
more_menu_open: bool,
|
||
read_only: bool,
|
||
) -> HostStatusPayload {
|
||
let current_block_index = hovered_block.as_ref().map(|block| block.index);
|
||
HostStatusPayload {
|
||
document_id,
|
||
workspace_id,
|
||
title,
|
||
dirty_count,
|
||
selected_block_index: current_block_index,
|
||
current_block_id: current_block_index.and_then(runtime_block_id_from_index),
|
||
editor_focused,
|
||
read_only,
|
||
slash_open,
|
||
toolbar_open: toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open),
|
||
}
|
||
}
|
||
|
||
fn dispatch_bridge_event<T>(event_name: &'static str, payload: &T)
|
||
where
|
||
T: Serialize,
|
||
{
|
||
dispatch_runtime_event(event_name, payload);
|
||
}
|
||
|
||
fn read_editor_snapshot(editor: TiptapEditorHandle) -> (String, Value, String) {
|
||
let html = editor
|
||
.get_html()
|
||
.unwrap_or_else(|err| format!("读取 HTML 失败:{err}"));
|
||
let json_value = editor.get_json().unwrap_or_else(|err| {
|
||
json!({
|
||
"type": "error",
|
||
"message": format!("读取 JSON 失败:{err}"),
|
||
})
|
||
});
|
||
let json_text = serde_json::to_string_pretty(&json_value)
|
||
.unwrap_or_else(|err| format!("格式化 JSON 失败:{err}"));
|
||
(html, json_value, json_text)
|
||
}
|
||
|
||
fn sync_editor_outputs(
|
||
editor: TiptapEditorHandle,
|
||
set_html_output: WriteSignal<String>,
|
||
set_document_json: WriteSignal<Value>,
|
||
set_json_output: WriteSignal<String>,
|
||
) -> Value {
|
||
let (html, json_value, json_text) = read_editor_snapshot(editor);
|
||
set_html_output.set(html);
|
||
set_document_json.set(json_value.clone());
|
||
set_json_output.set(json_text);
|
||
json_value
|
||
}
|
||
|
||
fn editor_root_element() -> Option<Element> {
|
||
window()
|
||
.and_then(|win| win.document())
|
||
.and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten())
|
||
}
|
||
|
||
fn editor_stage_element() -> Option<Element> {
|
||
window()
|
||
.and_then(|win| win.document())
|
||
.and_then(|document| {
|
||
document
|
||
.query_selector(EDITOR_STAGE_SELECTOR)
|
||
.ok()
|
||
.flatten()
|
||
})
|
||
}
|
||
|
||
fn content_column_width(stage_rect_width: f64) -> f64 {
|
||
let available = (stage_rect_width - (CONTENT_COLUMN_HORIZONTAL_PADDING * 2.0)).max(0.0);
|
||
available.min(CONTENT_COLUMN_MAX_WIDTH)
|
||
}
|
||
|
||
fn content_column_left(stage_rect_width: f64) -> f64 {
|
||
let column_width = content_column_width(stage_rect_width);
|
||
((stage_rect_width - column_width) / 2.0).max(0.0)
|
||
}
|
||
|
||
fn content_text_left(stage_rect_width: f64) -> f64 {
|
||
content_column_left(stage_rect_width) + CONTENT_COLUMN_HORIZONTAL_PADDING
|
||
}
|
||
|
||
fn handle_lane_left(stage_rect_width: f64) -> f64 {
|
||
(content_text_left(stage_rect_width) + HANDLE_TEXT_ALIGN_OFFSET
|
||
- HANDLE_TRIGGER_WIDTH
|
||
- HANDLE_TRIGGER_GAP
|
||
- HANDLE_MENU_GAP)
|
||
.max(HANDLE_STAGE_PADDING_LEFT)
|
||
}
|
||
|
||
fn direct_block_from_element(mut element: Element, root: &Element) -> Option<Element> {
|
||
loop {
|
||
let parent = element.parent_element()?;
|
||
if parent.is_same_node(Some(root)) {
|
||
return Some(element);
|
||
}
|
||
element = parent;
|
||
}
|
||
}
|
||
|
||
fn top_level_block_index(root: &Element, block: &Element) -> Option<usize> {
|
||
let children = root.children();
|
||
(0..children.length()).find_map(|index| {
|
||
children
|
||
.item(index)
|
||
.filter(|child| child.is_same_node(Some(block)))
|
||
.map(|_| index as usize)
|
||
})
|
||
}
|
||
|
||
fn block_label_for_element(block: &Element) -> String {
|
||
match block.get_attribute("data-type").as_deref() {
|
||
Some("taskList") => "Todo 列表".to_string(),
|
||
Some("taskItem") => "Todo 项".to_string(),
|
||
_ => match block.tag_name().as_str() {
|
||
"H1" => "一级标题".to_string(),
|
||
"H2" => "二级标题".to_string(),
|
||
"H3" => "三级标题".to_string(),
|
||
"P" => "段落".to_string(),
|
||
"UL" => "无序列表".to_string(),
|
||
"OL" => "有序列表".to_string(),
|
||
"BLOCKQUOTE" => "引用块".to_string(),
|
||
"PRE" => "代码块".to_string(),
|
||
"HR" => "分割线".to_string(),
|
||
other => format!("块节点 {other}"),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn target_element(target: web_sys::EventTarget) -> Option<Element> {
|
||
target.clone().dyn_into::<Element>().ok().or_else(|| {
|
||
target
|
||
.dyn_into::<Node>()
|
||
.ok()
|
||
.and_then(|node| node.parent_element())
|
||
})
|
||
}
|
||
|
||
fn event_target_matches_selector(target: Option<web_sys::EventTarget>, selector: &str) -> bool {
|
||
target
|
||
.and_then(target_element)
|
||
.and_then(|element| element.closest(selector).ok().flatten())
|
||
.is_some()
|
||
}
|
||
|
||
fn current_target_html_element(event: &WheelEvent) -> Option<HtmlElement> {
|
||
event.current_target()?.dyn_into::<HtmlElement>().ok()
|
||
}
|
||
|
||
fn trap_scroll_inside_menu(event: &WheelEvent) {
|
||
event.prevent_default();
|
||
event.stop_propagation();
|
||
|
||
let Some(menu) = current_target_html_element(event) else {
|
||
return;
|
||
};
|
||
|
||
let current = f64::from(menu.scroll_top());
|
||
let max_scroll = f64::from((menu.scroll_height() - menu.client_height()).max(0));
|
||
let next = (current + event.delta_y()).clamp(0.0, max_scroll);
|
||
menu.set_scroll_top(next.round() as i32);
|
||
}
|
||
|
||
fn hovered_block_from_target(target: web_sys::EventTarget) -> Option<HoveredBlockState> {
|
||
let root = editor_root_element()?;
|
||
let element = target_element(target)?;
|
||
|
||
if element
|
||
.closest(HANDLE_SHELL_SELECTOR)
|
||
.ok()
|
||
.flatten()
|
||
.is_some()
|
||
{
|
||
return None;
|
||
}
|
||
|
||
let block = direct_block_from_element(element, &root)?;
|
||
let index = top_level_block_index(&root, &block)?;
|
||
block_state_from_index(index)
|
||
}
|
||
|
||
fn hovered_block_from_selection() -> Option<HoveredBlockState> {
|
||
let selection = window().and_then(|win| win.get_selection().ok().flatten())?;
|
||
let anchor_node = selection.anchor_node()?;
|
||
let element = anchor_node
|
||
.dyn_ref::<Element>()
|
||
.cloned()
|
||
.or_else(|| anchor_node.parent_element())?;
|
||
let root = editor_root_element()?;
|
||
let block = direct_block_from_element(element, &root)?;
|
||
let index = top_level_block_index(&root, &block)?;
|
||
block_state_from_index(index)
|
||
}
|
||
|
||
fn block_state_from_index(index: usize) -> Option<HoveredBlockState> {
|
||
let root = editor_root_element()?;
|
||
let stage = editor_stage_element()?;
|
||
let block = root.children().item(index as u32)?;
|
||
let block_rect = block.get_bounding_client_rect();
|
||
let stage_rect = stage.get_bounding_client_rect();
|
||
let block_height = if block_rect.height() > 0.0 {
|
||
block_rect.height()
|
||
} else {
|
||
28.0
|
||
};
|
||
|
||
Some(HoveredBlockState {
|
||
index,
|
||
label: block_label_for_element(&block),
|
||
top: block_rect.top() - stage_rect.top(),
|
||
height: block_height,
|
||
})
|
||
}
|
||
|
||
fn prosemirror_node_size(node: &Value) -> Option<u32> {
|
||
match node.get("type").and_then(Value::as_str) {
|
||
Some("text") => Some(
|
||
node.get("text")
|
||
.and_then(Value::as_str)
|
||
.map(|text| text.encode_utf16().count() as u32)
|
||
.unwrap_or(0),
|
||
),
|
||
Some("hardBreak") | Some("horizontalRule") => Some(1),
|
||
_ => {
|
||
let Some(children) = node.get("content").and_then(Value::as_array) else {
|
||
return Some(1);
|
||
};
|
||
|
||
let content_size = children.iter().try_fold(0_u32, |acc, child| {
|
||
prosemirror_node_size(child).map(|size| acc + size)
|
||
})?;
|
||
Some(content_size + 2)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn nested_edge_child_size(node: &Value, at_start: bool) -> Option<u32> {
|
||
let outer = node
|
||
.get("content")
|
||
.and_then(Value::as_array)
|
||
.and_then(|children| {
|
||
if at_start {
|
||
children.first()
|
||
} else {
|
||
children.last()
|
||
}
|
||
})?;
|
||
let inner = outer
|
||
.get("content")
|
||
.and_then(Value::as_array)
|
||
.and_then(|children| {
|
||
if at_start {
|
||
children.first()
|
||
} else {
|
||
children.last()
|
||
}
|
||
})?;
|
||
prosemirror_node_size(inner)
|
||
}
|
||
|
||
fn top_level_block_range(document: &Value, index: usize) -> Option<TiptapRange> {
|
||
let content = document.get("content").and_then(Value::as_array)?;
|
||
let mut position = 0_u32;
|
||
|
||
for (current_index, node) in content.iter().enumerate() {
|
||
let node_size = prosemirror_node_size(node)?;
|
||
if current_index == index {
|
||
let from = position + nested_edge_child_size(node, true).unwrap_or(1);
|
||
let mut to = position
|
||
+ node_size.saturating_sub(nested_edge_child_size(node, false).unwrap_or(1));
|
||
if to < from {
|
||
to = from;
|
||
}
|
||
return Some(TiptapRange { from, to });
|
||
}
|
||
position += node_size;
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn node_is_within_root(node: Node, root: &Element) -> bool {
|
||
let mut current = Some(node);
|
||
while let Some(node) = current {
|
||
if node.is_same_node(Some(root)) {
|
||
return true;
|
||
}
|
||
current = node.parent_node();
|
||
}
|
||
false
|
||
}
|
||
|
||
fn active_editor_text_selection() -> Option<web_sys::Selection> {
|
||
let selection = window().and_then(|win| win.get_selection().ok().flatten())?;
|
||
if selection.is_collapsed() {
|
||
return None;
|
||
}
|
||
|
||
let root = editor_root_element()?;
|
||
let anchor_inside = selection
|
||
.anchor_node()
|
||
.map(|node| node_is_within_root(node, &root))
|
||
.unwrap_or(false);
|
||
let focus_inside = selection
|
||
.focus_node()
|
||
.map(|node| node_is_within_root(node, &root))
|
||
.unwrap_or(false);
|
||
|
||
if anchor_inside || focus_inside {
|
||
Some(selection)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn floating_toolbar_anchor_from_selection() -> Option<FloatingToolbarAnchor> {
|
||
let selection = active_editor_text_selection()?;
|
||
let range = selection.get_range_at(0).ok()?;
|
||
let rect = range.get_bounding_client_rect();
|
||
if rect.width() <= 0.0 && rect.height() <= 0.0 {
|
||
return None;
|
||
}
|
||
|
||
let stage = editor_stage_element()?;
|
||
let stage_rect = stage.get_bounding_client_rect();
|
||
|
||
Some(FloatingToolbarAnchor {
|
||
top: (rect.top() - stage_rect.top()).max(60.0),
|
||
left: rect.left() + (rect.width() / 2.0) - stage_rect.left(),
|
||
})
|
||
}
|
||
|
||
fn hover_anchor_top(block: &HoveredBlockState) -> f64 {
|
||
if block.height > HANDLE_MULTILINE_HEIGHT {
|
||
block.top + 12.0
|
||
} else {
|
||
block.top + (block.height / 2.0) - 32.0
|
||
}
|
||
}
|
||
|
||
fn hover_anchor_transform(_block: &HoveredBlockState) -> &'static str {
|
||
"none"
|
||
}
|
||
|
||
fn pointer_in_handle_corridor(client_x: i32, client_y: i32, block: &HoveredBlockState) -> bool {
|
||
let Some(stage) = editor_stage_element() else {
|
||
return false;
|
||
};
|
||
let Some(root) = editor_root_element() else {
|
||
return false;
|
||
};
|
||
let Some(block_element) = root.children().item(block.index as u32) else {
|
||
return false;
|
||
};
|
||
|
||
let stage_rect = stage.get_bounding_client_rect();
|
||
let block_rect = block_element.get_bounding_client_rect();
|
||
let handle_rect = window()
|
||
.and_then(|win| win.document())
|
||
.and_then(|document| {
|
||
document
|
||
.query_selector(HANDLE_SHELL_SELECTOR)
|
||
.ok()
|
||
.flatten()
|
||
})
|
||
.map(|handle| handle.get_bounding_client_rect());
|
||
|
||
let block_left = block_rect.left() - stage_rect.left();
|
||
let block_top = block_rect.top() - stage_rect.top();
|
||
let block_bottom = block_rect.bottom() - stage_rect.top();
|
||
let x = f64::from(client_x) - stage_rect.left();
|
||
let y = f64::from(client_y) - stage_rect.top();
|
||
let handle_left = handle_rect
|
||
.as_ref()
|
||
.map(|rect| rect.left() - stage_rect.left())
|
||
.unwrap_or_else(|| handle_lane_left(stage_rect.width()));
|
||
let handle_right = handle_rect
|
||
.as_ref()
|
||
.map(|rect| rect.right() - stage_rect.left())
|
||
.unwrap_or_else(|| handle_left + HANDLE_TRIGGER_WIDTH);
|
||
let corridor_left = (handle_left - 12.0).min(block_left);
|
||
let corridor_right = (block_left + 18.0).max(handle_right + 12.0);
|
||
|
||
x >= corridor_left && x <= corridor_right && y >= block_top - 18.0 && y <= block_bottom + 18.0
|
||
}
|
||
|
||
fn block_menu_layout(block: &HoveredBlockState) -> Option<BlockMenuLayout> {
|
||
let stage = editor_stage_element()?;
|
||
let stage_rect = stage.get_bounding_client_rect();
|
||
let shell_top = hover_anchor_top(block);
|
||
let stage_padding = 18.0;
|
||
let space_above = (shell_top - stage_padding).max(0.0);
|
||
let space_below = (stage_rect.height() - shell_top - stage_padding).max(0.0);
|
||
let open_upward = space_below < 340.0 && space_above > space_below;
|
||
let available_height = if open_upward {
|
||
space_above
|
||
} else {
|
||
space_below
|
||
};
|
||
let max_height = if available_height >= 220.0 {
|
||
available_height
|
||
} else {
|
||
available_height.max(160.0)
|
||
};
|
||
let menu_width = 242.0;
|
||
let menu_stage_left =
|
||
(content_text_left(stage_rect.width()) - menu_width - 132.0).max(HANDLE_MENU_LEFT_LIMIT);
|
||
let viewport_shell_top = stage_rect.top() + shell_top;
|
||
let top = if open_upward {
|
||
viewport_shell_top + 64.0 - max_height
|
||
} else {
|
||
viewport_shell_top
|
||
}
|
||
.max(8.0);
|
||
let left = (stage_rect.left() + menu_stage_left).max(8.0);
|
||
|
||
Some(BlockMenuLayout {
|
||
max_height,
|
||
open_upward,
|
||
top,
|
||
left,
|
||
})
|
||
}
|
||
|
||
fn drop_indicator_from_target(
|
||
target: web_sys::EventTarget,
|
||
client_y: i32,
|
||
) -> Option<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::AiAssistant
|
||
| SlashActionKind::AiWrite
|
||
| SlashActionKind::ContinueWriting
|
||
| SlashActionKind::Summarize
|
||
| SlashActionKind::MoreAi => paragraph_node(inline.clone()),
|
||
SlashActionKind::Paragraph => paragraph_node(inline),
|
||
SlashActionKind::Heading1 => {
|
||
let mut attrs = Map::new();
|
||
attrs.insert("level".to_string(), json!(1));
|
||
node_with_attrs("heading", Some(attrs), inline)
|
||
}
|
||
SlashActionKind::Heading2 => {
|
||
let mut attrs = Map::new();
|
||
attrs.insert("level".to_string(), json!(2));
|
||
node_with_attrs("heading", Some(attrs), inline)
|
||
}
|
||
SlashActionKind::Heading3 => {
|
||
let mut attrs = Map::new();
|
||
attrs.insert("level".to_string(), json!(3));
|
||
node_with_attrs("heading", Some(attrs), inline)
|
||
}
|
||
SlashActionKind::Heading4 => {
|
||
let mut attrs = Map::new();
|
||
attrs.insert("level".to_string(), json!(4));
|
||
node_with_attrs("heading", Some(attrs), inline)
|
||
}
|
||
SlashActionKind::BulletList => {
|
||
node_with_attrs("bulletList", None, vec![list_item_node(inline)])
|
||
}
|
||
SlashActionKind::OrderedList => {
|
||
node_with_attrs("orderedList", None, vec![list_item_node(inline)])
|
||
}
|
||
SlashActionKind::Todo | SlashActionKind::AdvancedTodo => {
|
||
node_with_attrs("taskList", None, vec![task_item_node(inline)])
|
||
}
|
||
SlashActionKind::Quote => node_with_attrs("blockquote", None, vec![paragraph_node(inline)]),
|
||
SlashActionKind::CodeBlock => {
|
||
let text = collect_plain_text(node);
|
||
let mut attrs = Map::new();
|
||
attrs.insert("language".to_string(), Value::String("rust".to_string()));
|
||
let content = if text.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
vec![json!({"type": "text", "text": text})]
|
||
};
|
||
node_with_attrs("codeBlock", Some(attrs), content)
|
||
}
|
||
SlashActionKind::Divider => node_with_attrs("horizontalRule", None, Vec::new()),
|
||
}
|
||
}
|
||
|
||
fn duplicate_top_level_block(document: &mut Value, index: usize) -> Result<(), String> {
|
||
let content = document_content_mut(document)?;
|
||
let block = content
|
||
.get(index)
|
||
.cloned()
|
||
.ok_or_else(|| format!("找不到第 {index} 个块"))?;
|
||
content.insert(index + 1, block);
|
||
Ok(())
|
||
}
|
||
|
||
fn delete_top_level_block(document: &mut Value, index: usize) -> Result<(), String> {
|
||
let content = document_content_mut(document)?;
|
||
if index >= content.len() {
|
||
return Err(format!("找不到第 {index} 个块"));
|
||
}
|
||
|
||
if content.len() == 1 {
|
||
content[0] = paragraph_node(Vec::new());
|
||
return Ok(());
|
||
}
|
||
|
||
content.remove(index);
|
||
Ok(())
|
||
}
|
||
|
||
fn reorder_top_level_block(
|
||
document: &mut Value,
|
||
source: usize,
|
||
target: usize,
|
||
placement: DropPlacement,
|
||
) -> Result<(), String> {
|
||
let content = document_content_mut(document)?;
|
||
if source >= content.len() || target >= content.len() {
|
||
return Err("拖拽目标超出当前顶层块范围".to_string());
|
||
}
|
||
|
||
let block = content.remove(source);
|
||
let mut insert_at = match placement {
|
||
DropPlacement::Before => target,
|
||
DropPlacement::After => target + 1,
|
||
};
|
||
|
||
if source < insert_at {
|
||
insert_at = insert_at.saturating_sub(1);
|
||
}
|
||
|
||
if insert_at > content.len() {
|
||
insert_at = content.len();
|
||
}
|
||
|
||
content.insert(insert_at, block);
|
||
Ok(())
|
||
}
|
||
|
||
fn replace_top_level_block_kind(
|
||
document: &mut Value,
|
||
index: usize,
|
||
action: SlashActionKind,
|
||
) -> Result<(), String> {
|
||
let content = document_content_mut(document)?;
|
||
let next = turn_into_block(
|
||
content
|
||
.get(index)
|
||
.ok_or_else(|| format!("找不到第 {index} 个块"))?,
|
||
action,
|
||
);
|
||
content[index] = next;
|
||
Ok(())
|
||
}
|
||
|
||
fn has_active_text_selection() -> bool {
|
||
active_editor_text_selection().is_some()
|
||
}
|
||
|
||
fn toolbar_overlay_locked(
|
||
turn_into_open: bool,
|
||
color_menu_open: bool,
|
||
more_menu_open: bool,
|
||
) -> bool {
|
||
turn_into_open || color_menu_open || more_menu_open
|
||
}
|
||
|
||
fn sync_text_selection_overlay(
|
||
set_text_selection_active: WriteSignal<bool>,
|
||
set_floating_toolbar_anchor: WriteSignal<Option<FloatingToolbarAnchor>>,
|
||
) -> bool {
|
||
let toolbar_anchor = floating_toolbar_anchor_from_selection();
|
||
let has_text_selection = toolbar_anchor.is_some();
|
||
set_text_selection_active.set(has_text_selection);
|
||
set_floating_toolbar_anchor.set(toolbar_anchor);
|
||
has_text_selection
|
||
}
|
||
|
||
fn sync_editor_overlay_state(
|
||
set_editor_focused: WriteSignal<bool>,
|
||
set_text_selection_active: WriteSignal<bool>,
|
||
set_floating_toolbar_anchor: WriteSignal<Option<FloatingToolbarAnchor>>,
|
||
set_slash_open: WriteSignal<bool>,
|
||
set_turn_into_open: WriteSignal<bool>,
|
||
set_hovered_block: WriteSignal<Option<HoveredBlockState>>,
|
||
set_block_menu_anchor: WriteSignal<Option<HoveredBlockState>>,
|
||
set_block_menu_open: WriteSignal<bool>,
|
||
) {
|
||
let focused = active_editor_stage();
|
||
set_editor_focused.set(focused);
|
||
let has_text_selection =
|
||
sync_text_selection_overlay(set_text_selection_active, set_floating_toolbar_anchor);
|
||
|
||
if focused && has_text_selection {
|
||
set_slash_open.set(false);
|
||
set_turn_into_open.set(false);
|
||
set_block_menu_open.set(false);
|
||
set_block_menu_anchor.set(None);
|
||
set_hovered_block.set(None);
|
||
}
|
||
}
|
||
|
||
fn try_sync_editor_overlay_state(
|
||
set_editor_focused: WriteSignal<bool>,
|
||
set_text_selection_active: WriteSignal<bool>,
|
||
set_floating_toolbar_anchor: WriteSignal<Option<FloatingToolbarAnchor>>,
|
||
set_slash_open: WriteSignal<bool>,
|
||
set_turn_into_open: WriteSignal<bool>,
|
||
set_hovered_block: WriteSignal<Option<HoveredBlockState>>,
|
||
set_block_menu_anchor: WriteSignal<Option<HoveredBlockState>>,
|
||
set_block_menu_open: WriteSignal<bool>,
|
||
) -> bool {
|
||
let focused = active_editor_stage();
|
||
if set_editor_focused.try_set(focused).is_none() {
|
||
return false;
|
||
}
|
||
|
||
let toolbar_anchor = floating_toolbar_anchor_from_selection();
|
||
let has_text_selection = toolbar_anchor.is_some();
|
||
if set_text_selection_active
|
||
.try_set(has_text_selection)
|
||
.is_none()
|
||
{
|
||
return false;
|
||
}
|
||
if set_floating_toolbar_anchor
|
||
.try_set(toolbar_anchor)
|
||
.is_none()
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if focused && has_text_selection {
|
||
if set_slash_open.try_set(false).is_none() {
|
||
return false;
|
||
}
|
||
if set_turn_into_open.try_set(false).is_none() {
|
||
return false;
|
||
}
|
||
if set_block_menu_open.try_set(false).is_none() {
|
||
return false;
|
||
}
|
||
if set_block_menu_anchor.try_set(None).is_none() {
|
||
return false;
|
||
}
|
||
if set_hovered_block.try_set(None).is_none() {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
true
|
||
}
|
||
|
||
fn apply_document_update(
|
||
editor: TiptapEditorHandle,
|
||
persisted_identity: &PersistedDocumentIdentity,
|
||
next_document: Value,
|
||
set_dirty_count: WriteSignal<u32>,
|
||
set_html_output: WriteSignal<String>,
|
||
set_document_json: WriteSignal<Value>,
|
||
set_json_output: WriteSignal<String>,
|
||
title: ReadSignal<String>,
|
||
set_command_feedback: WriteSignal<String>,
|
||
success_message: impl Into<String>,
|
||
) {
|
||
let success_message = success_message.into();
|
||
let next_payload = next_document
|
||
.get("content")
|
||
.and_then(Value::as_array)
|
||
.cloned()
|
||
.map(Value::Array)
|
||
.unwrap_or_else(|| next_document.clone());
|
||
match editor.set_content(TiptapContent::json(next_payload)) {
|
||
Ok(()) => {
|
||
set_dirty_count.update(|count| *count += 1);
|
||
let (html, snapshot, json_text) = read_editor_snapshot(editor);
|
||
set_html_output.set(html.clone());
|
||
set_document_json.set(snapshot.clone());
|
||
set_json_output.set(json_text);
|
||
match persist_document_state(
|
||
persisted_identity,
|
||
&title.get_untracked(),
|
||
&snapshot,
|
||
Some(html),
|
||
) {
|
||
Ok(()) => {
|
||
set_command_feedback.set(format!("{success_message},并已写入本地草稿"));
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(format!("{success_message},但本地保存失败:{err}"));
|
||
}
|
||
}
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(format!("更新文档失败:{err}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn apply_html_update(
|
||
editor: TiptapEditorHandle,
|
||
persisted_identity: &PersistedDocumentIdentity,
|
||
next_html: String,
|
||
set_dirty_count: WriteSignal<u32>,
|
||
set_html_output: WriteSignal<String>,
|
||
set_document_json: WriteSignal<Value>,
|
||
set_json_output: WriteSignal<String>,
|
||
title: ReadSignal<String>,
|
||
set_command_feedback: WriteSignal<String>,
|
||
success_message: impl Into<String>,
|
||
) {
|
||
let success_message = success_message.into();
|
||
match editor.set_content(TiptapContent::html(next_html)) {
|
||
Ok(()) => {
|
||
set_dirty_count.update(|count| *count += 1);
|
||
let (html, snapshot, json_text) = read_editor_snapshot(editor);
|
||
set_html_output.set(html.clone());
|
||
set_document_json.set(snapshot.clone());
|
||
set_json_output.set(json_text);
|
||
match persist_document_state(
|
||
persisted_identity,
|
||
&title.get_untracked(),
|
||
&snapshot,
|
||
Some(html),
|
||
) {
|
||
Ok(()) => {
|
||
set_command_feedback.set(format!("{success_message},并已写入本地草稿"));
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(format!("{success_message},但本地保存失败:{err}"));
|
||
}
|
||
}
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(format!("更新文档失败:{err}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn duplicate_top_level_block_html(current_html: &str, index: usize) -> Result<String, String> {
|
||
let document = window()
|
||
.and_then(|win| win.document())
|
||
.ok_or_else(|| "浏览器 document 不可用".to_string())?;
|
||
let container = document
|
||
.create_element("div")
|
||
.map_err(|err| format!("创建 HTML 容器失败:{err:?}"))?;
|
||
container.set_inner_html(current_html);
|
||
|
||
let children = container.children();
|
||
let current = children
|
||
.item(index as u32)
|
||
.ok_or_else(|| format!("找不到第 {index} 个 HTML 顶层块"))?;
|
||
let cloned = current
|
||
.clone_node_with_deep(true)
|
||
.map_err(|err| format!("复制 HTML 块失败:{err:?}"))?
|
||
.dyn_into::<Element>()
|
||
.map_err(|_| "复制出的节点不是 Element".to_string())?;
|
||
|
||
if let Some(next_sibling) = children.item(index as u32 + 1) {
|
||
container
|
||
.insert_before(&cloned, Some(&next_sibling))
|
||
.map_err(|err| format!("插入复制块失败:{err:?}"))?;
|
||
} else {
|
||
container
|
||
.append_child(&cloned)
|
||
.map_err(|err| format!("追加复制块失败:{err:?}"))?;
|
||
}
|
||
|
||
Ok(container.inner_html())
|
||
}
|
||
|
||
fn top_level_block_boundary_position(document: &Value, index: usize, before: bool) -> Option<u32> {
|
||
let content = document.get("content").and_then(Value::as_array)?;
|
||
let mut position = 0_u32;
|
||
|
||
for (current_index, node) in content.iter().enumerate() {
|
||
let node_size = prosemirror_node_size(node)?;
|
||
if current_index == index {
|
||
return Some(if before {
|
||
position
|
||
} else {
|
||
position + node_size
|
||
});
|
||
}
|
||
position += node_size;
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn insert_editor_paragraph_relative_to_block(
|
||
editor: TiptapEditorHandle,
|
||
index: usize,
|
||
before: bool,
|
||
) -> Result<usize, String> {
|
||
let document = editor
|
||
.get_json()
|
||
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
|
||
let position = top_level_block_boundary_position(&document, index, before)
|
||
.ok_or_else(|| format!("找不到第 {} 个块的插入位置", index + 1))?;
|
||
editor
|
||
.insert_content_at(
|
||
position,
|
||
TiptapContent::json(json!({ "type": "paragraph" })),
|
||
Some(TiptapInsertContentOptions {
|
||
update_selection: Some(true),
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.map_err(|err| format!("插入新块失败:{err}"))?;
|
||
let new_index = if before { index } else { index + 1 };
|
||
focus_top_level_block_start(editor, new_index)?;
|
||
Ok(new_index)
|
||
}
|
||
|
||
fn sync_persisted_editor_command(
|
||
editor: TiptapEditorHandle,
|
||
persisted_identity: &PersistedDocumentIdentity,
|
||
set_dirty_count: WriteSignal<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();
|
||
set_dirty_count.update(|count| *count += 1);
|
||
let (html, snapshot, json_text) = read_editor_snapshot(editor);
|
||
set_html_output.set(html.clone());
|
||
set_document_json.set(snapshot.clone());
|
||
set_json_output.set(json_text);
|
||
match persist_document_state(
|
||
persisted_identity,
|
||
&title.get_untracked(),
|
||
&snapshot,
|
||
Some(html),
|
||
) {
|
||
Ok(()) => set_command_feedback.set(format!("{success_message},并已写入本地草稿")),
|
||
Err(err) => set_command_feedback.set(format!("{success_message},但本地保存失败:{err}")),
|
||
}
|
||
}
|
||
|
||
fn focus_top_level_block_start(editor: TiptapEditorHandle, index: usize) -> Result<(), String> {
|
||
editor
|
||
.focus()
|
||
.map_err(|err| format!("聚焦编辑器失败:{err}"))?;
|
||
|
||
if let (Some(root), Some(document), Some(selection)) = (
|
||
editor_root_element(),
|
||
window().and_then(|win| win.document()),
|
||
window().and_then(|win| win.get_selection().ok().flatten()),
|
||
) {
|
||
if let Some(block) = root.children().item(index as u32) {
|
||
let range = document
|
||
.create_range()
|
||
.map_err(|err| format!("创建新块光标失败:{err:?}"))?;
|
||
range
|
||
.select_node_contents(block.unchecked_ref::<Node>())
|
||
.map_err(|err| format!("选择新块失败:{err:?}"))?;
|
||
range.collapse_with_to_start(true);
|
||
selection
|
||
.remove_all_ranges()
|
||
.map_err(|err| format!("清理旧选区失败:{err:?}"))?;
|
||
selection
|
||
.add_range(&range)
|
||
.map_err(|err| format!("写入新块选区失败:{err:?}"))?;
|
||
return Ok(());
|
||
}
|
||
}
|
||
|
||
let document = editor
|
||
.get_json()
|
||
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
|
||
let range = top_level_block_range(&document, index)
|
||
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", index + 1))?;
|
||
editor
|
||
.set_text_selection(range.from)
|
||
.map_err(|err| format!("定位新块失败:{err}"))
|
||
}
|
||
|
||
fn delete_top_level_block_html(current_html: &str, index: usize) -> Result<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 {
|
||
let active_element_inside_stage = window()
|
||
.and_then(|win| win.document())
|
||
.and_then(|document| document.active_element())
|
||
.and_then(|element| element.closest(EDITOR_STAGE_SELECTOR).ok().flatten())
|
||
.is_some();
|
||
|
||
if active_element_inside_stage {
|
||
return true;
|
||
}
|
||
|
||
let Some(root) = editor_root_element() else {
|
||
return false;
|
||
};
|
||
let Some(selection) = window().and_then(|win| win.get_selection().ok().flatten()) else {
|
||
return false;
|
||
};
|
||
|
||
let anchor_inside = selection
|
||
.anchor_node()
|
||
.map(|node| node_is_within_root(node, &root))
|
||
.unwrap_or(false);
|
||
let focus_inside = selection
|
||
.focus_node()
|
||
.map(|node| node_is_within_root(node, &root))
|
||
.unwrap_or(false);
|
||
|
||
anchor_inside || focus_inside
|
||
}
|
||
|
||
fn selection_summary(selection: &TiptapSelectionState) -> String {
|
||
let block = if selection.h1 {
|
||
"一级标题"
|
||
} else if selection.h2 {
|
||
"二级标题"
|
||
} else if selection.h3 {
|
||
"三级标题"
|
||
} else if selection.task_list {
|
||
"Todo 列表"
|
||
} else if selection.ordered_list {
|
||
"有序列表"
|
||
} else if selection.bullet_list {
|
||
"无序列表"
|
||
} else if selection.blockquote {
|
||
"引用块"
|
||
} else if selection.paragraph {
|
||
"段落"
|
||
} else {
|
||
"未定位"
|
||
};
|
||
|
||
let mut marks = Vec::new();
|
||
if selection.bold {
|
||
marks.push("Bold");
|
||
}
|
||
if selection.italic {
|
||
marks.push("Italic");
|
||
}
|
||
if selection.underline {
|
||
marks.push("Underline");
|
||
}
|
||
if selection.strike {
|
||
marks.push("Strike");
|
||
}
|
||
if selection.highlight {
|
||
marks.push("Highlight");
|
||
}
|
||
if selection.text_style {
|
||
marks.push("Color");
|
||
}
|
||
if selection.link {
|
||
marks.push("Link");
|
||
}
|
||
|
||
let mark_text = if marks.is_empty() {
|
||
"无行内样式".to_string()
|
||
} else {
|
||
marks.join(" / ")
|
||
};
|
||
|
||
format!("当前块:{block} | 行内样式:{mark_text}")
|
||
}
|
||
|
||
fn current_block_info_from_index(index: Option<usize>) -> CurrentBlockInfo {
|
||
index
|
||
.map(|value| CurrentBlockInfo {
|
||
index: Some(value),
|
||
block_id: runtime_block_id_from_index(value),
|
||
})
|
||
.unwrap_or(CurrentBlockInfo {
|
||
index: None,
|
||
block_id: None,
|
||
})
|
||
}
|
||
|
||
fn selection_payload(
|
||
selection: &TiptapSelectionState,
|
||
editor_focused: bool,
|
||
current_block_index: Option<usize>,
|
||
) -> SelectionPayload {
|
||
SelectionPayload {
|
||
selection: selection.clone(),
|
||
summary: selection_summary(selection),
|
||
editor_focused,
|
||
current_block_index,
|
||
current_block_id: current_block_index.and_then(runtime_block_id_from_index),
|
||
}
|
||
}
|
||
|
||
fn state_payload(
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: String,
|
||
dirty_count: u32,
|
||
hovered_block: Option<HoveredBlockState>,
|
||
editor_focused: bool,
|
||
slash_open: bool,
|
||
turn_into_open: bool,
|
||
color_menu_open: bool,
|
||
more_menu_open: bool,
|
||
read_only: bool,
|
||
) -> StatePayload {
|
||
let toolbar_open = toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open);
|
||
StatePayload {
|
||
document_id,
|
||
workspace_id,
|
||
title,
|
||
dirty_count,
|
||
selected_block_index: hovered_block.as_ref().map(|block| block.index),
|
||
editor_focused,
|
||
slash_open,
|
||
toolbar_open,
|
||
read_only,
|
||
}
|
||
}
|
||
|
||
fn status_payload(
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: String,
|
||
dirty_count: u32,
|
||
hovered_block: Option<HoveredBlockState>,
|
||
editor_focused: bool,
|
||
slash_open: bool,
|
||
turn_into_open: bool,
|
||
color_menu_open: bool,
|
||
more_menu_open: bool,
|
||
read_only: bool,
|
||
) -> HostStatusPayload {
|
||
let current_block_index = hovered_block.as_ref().map(|block| block.index);
|
||
let current_block_id = current_block_index.and_then(runtime_block_id_from_index);
|
||
HostStatusPayload {
|
||
document_id,
|
||
workspace_id,
|
||
title,
|
||
dirty_count,
|
||
selected_block_index: current_block_index,
|
||
current_block_id,
|
||
editor_focused,
|
||
read_only,
|
||
slash_open,
|
||
toolbar_open: toolbar_overlay_locked(turn_into_open, color_menu_open, more_menu_open),
|
||
}
|
||
}
|
||
|
||
fn dispatch_runtime_state(
|
||
document_id: Option<String>,
|
||
workspace_id: Option<String>,
|
||
title: String,
|
||
dirty_count: u32,
|
||
hovered_block: Option<HoveredBlockState>,
|
||
editor_focused: bool,
|
||
slash_open: bool,
|
||
turn_into_open: bool,
|
||
color_menu_open: bool,
|
||
more_menu_open: bool,
|
||
read_only: bool,
|
||
) {
|
||
let state = state_payload(
|
||
document_id.clone(),
|
||
workspace_id.clone(),
|
||
title.clone(),
|
||
dirty_count,
|
||
hovered_block.clone(),
|
||
editor_focused,
|
||
slash_open,
|
||
turn_into_open,
|
||
color_menu_open,
|
||
more_menu_open,
|
||
read_only,
|
||
);
|
||
dispatch_state_event(&state);
|
||
dispatch_status_event(&status_payload(
|
||
document_id,
|
||
workspace_id,
|
||
title,
|
||
dirty_count,
|
||
hovered_block,
|
||
editor_focused,
|
||
slash_open,
|
||
turn_into_open,
|
||
color_menu_open,
|
||
more_menu_open,
|
||
read_only,
|
||
));
|
||
}
|
||
|
||
fn send_selection_state(
|
||
selection: &TiptapSelectionState,
|
||
editor_focused: bool,
|
||
current_block_index: Option<usize>,
|
||
) {
|
||
dispatch_selection_event(&selection_payload(
|
||
selection,
|
||
editor_focused,
|
||
current_block_index,
|
||
));
|
||
}
|
||
|
||
fn apply_host_document_payload(
|
||
editor: TiptapEditorHandle,
|
||
payload: HostDocumentPayload,
|
||
set_document_id: WriteSignal<Option<String>>,
|
||
set_workspace_id: WriteSignal<Option<String>>,
|
||
set_title: WriteSignal<String>,
|
||
set_read_only: WriteSignal<bool>,
|
||
set_revision: WriteSignal<Option<i64>>,
|
||
set_conflict_detection_key: WriteSignal<Option<String>>,
|
||
set_dirty_count: WriteSignal<u32>,
|
||
set_html_output: WriteSignal<String>,
|
||
set_document_json: WriteSignal<Value>,
|
||
set_json_output: WriteSignal<String>,
|
||
set_command_feedback: WriteSignal<String>,
|
||
) {
|
||
let next_document_id = payload.document_id.clone();
|
||
let next_workspace_id = payload.workspace_id.clone();
|
||
let next_title = payload.title.clone();
|
||
let next_revision = payload.revision;
|
||
let next_conflict_detection_key = payload.conflict_detection_key.clone();
|
||
let next_read_only = payload.read_only.unwrap_or(false);
|
||
|
||
set_document_id.set(next_document_id);
|
||
set_workspace_id.set(next_workspace_id);
|
||
if let Some(next_title_value) = next_title {
|
||
set_title.set(next_title_value);
|
||
}
|
||
set_read_only.set(next_read_only);
|
||
set_revision.set(next_revision);
|
||
set_conflict_detection_key.set(next_conflict_detection_key.clone());
|
||
|
||
if let Some(content) = payload.content {
|
||
let next_content = TiptapContent::json(content);
|
||
match editor.set_content(next_content) {
|
||
Ok(()) => {
|
||
let _snapshot = sync_editor_outputs(
|
||
editor,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
);
|
||
set_dirty_count.set(0);
|
||
set_command_feedback.set("宿主文档已同步到编辑器".to_string());
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(format!("宿主文档同步失败:{err}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn bridge_ready_payload() -> ReadyPayload {
|
||
ReadyPayload {
|
||
runtime_name: RUNTIME_NAME,
|
||
selectors: BridgeSelectorsPayload {
|
||
root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
|
||
stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
|
||
editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
|
||
toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
|
||
slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
|
||
handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
|
||
},
|
||
supported_commands: vec![
|
||
"replaceContent",
|
||
"setPageOptions",
|
||
"setEditable",
|
||
"undo",
|
||
"redo",
|
||
"focus",
|
||
"requestCurrentBlockId",
|
||
],
|
||
supports_embedded_mode: true,
|
||
}
|
||
}
|
||
|
||
fn selection_has_rich_marks(selection: &TiptapSelectionState) -> bool {
|
||
selection.bold
|
||
|| selection.italic
|
||
|| selection.underline
|
||
|| selection.strike
|
||
|| selection.highlight
|
||
|| selection.text_style
|
||
|| selection.link
|
||
}
|
||
|
||
fn apply_text_color(editor: TiptapEditorHandle, color: &str) -> Result<&'static str, String> {
|
||
editor
|
||
.set_color(TiptapColorAttributes {
|
||
color: color.to_string(),
|
||
})
|
||
.map(|_| "已更新文字颜色")
|
||
.map_err(|err| format!("命令执行失败:{err}"))
|
||
}
|
||
|
||
fn clear_text_color(editor: TiptapEditorHandle) -> Result<&'static str, String> {
|
||
editor
|
||
.unset_color()
|
||
.map(|_| "已清除文字颜色")
|
||
.map_err(|err| format!("命令执行失败:{err}"))
|
||
}
|
||
|
||
fn apply_highlight_color(
|
||
editor: TiptapEditorHandle,
|
||
color: Option<&str>,
|
||
) -> Result<&'static str, String> {
|
||
match color {
|
||
Some(color) => editor
|
||
.set_highlight(Some(TiptapHighlightAttributes {
|
||
color: Some(color.to_string()),
|
||
}))
|
||
.map(|_| "已更新背景色")
|
||
.map_err(|err| format!("命令执行失败:{err}")),
|
||
None => editor
|
||
.unset_highlight()
|
||
.map(|_| "已清除背景色")
|
||
.map_err(|err| format!("命令执行失败:{err}")),
|
||
}
|
||
}
|
||
|
||
fn apply_text_align(
|
||
editor: TiptapEditorHandle,
|
||
alignment: TiptapTextAlign,
|
||
) -> Result<&'static str, String> {
|
||
let message = match alignment {
|
||
TiptapTextAlign::Left => "已切到左对齐",
|
||
TiptapTextAlign::Center => "已切到居中",
|
||
TiptapTextAlign::Right => "已切到右对齐",
|
||
TiptapTextAlign::Justify => "已切到两端对齐",
|
||
};
|
||
editor
|
||
.set_text_align(alignment)
|
||
.map(|_| message)
|
||
.map_err(|err| format!("命令执行失败:{err}"))
|
||
}
|
||
|
||
fn run_slash_action(
|
||
editor: TiptapEditorHandle,
|
||
action: SlashActionKind,
|
||
) -> Result<&'static str, String> {
|
||
let result = match action {
|
||
SlashActionKind::AiAssistant
|
||
| SlashActionKind::AiWrite
|
||
| SlashActionKind::ContinueWriting
|
||
| SlashActionKind::Summarize
|
||
| SlashActionKind::MoreAi => return Ok("AI 命令入口已打开"),
|
||
SlashActionKind::Paragraph => editor.set_paragraph(),
|
||
SlashActionKind::Heading1 => editor.toggle_heading(TiptapHeadingLevel::H1),
|
||
SlashActionKind::Heading2 => editor.toggle_heading(TiptapHeadingLevel::H2),
|
||
SlashActionKind::Heading3 => editor.toggle_heading(TiptapHeadingLevel::H3),
|
||
SlashActionKind::Heading4 => editor.toggle_heading(TiptapHeadingLevel::H4),
|
||
SlashActionKind::BulletList => editor.toggle_bullet_list(),
|
||
SlashActionKind::OrderedList => editor.toggle_ordered_list(),
|
||
SlashActionKind::Todo | SlashActionKind::AdvancedTodo => editor.toggle_task_list(),
|
||
SlashActionKind::Quote => editor.toggle_blockquote(),
|
||
SlashActionKind::CodeBlock => editor.toggle_code_block(Some(TiptapCodeBlockAttributes {
|
||
language: Some("rust".into()),
|
||
})),
|
||
SlashActionKind::Divider => editor.set_horizontal_rule(),
|
||
};
|
||
|
||
result
|
||
.map(|_| match action {
|
||
SlashActionKind::AiAssistant
|
||
| SlashActionKind::AiWrite
|
||
| SlashActionKind::ContinueWriting
|
||
| SlashActionKind::Summarize
|
||
| SlashActionKind::MoreAi => "AI 命令入口已打开",
|
||
SlashActionKind::Paragraph => "已切到文本",
|
||
SlashActionKind::Heading1 => "已切到一级标题",
|
||
SlashActionKind::Heading2 => "已切到二级标题",
|
||
SlashActionKind::Heading3 => "已切到三级标题",
|
||
SlashActionKind::Heading4 => "已切到四级标题",
|
||
SlashActionKind::BulletList => "已切到无序列表",
|
||
SlashActionKind::OrderedList => "已切到有序列表",
|
||
SlashActionKind::Todo | SlashActionKind::AdvancedTodo => "已切到待办列表",
|
||
SlashActionKind::Quote => "已切到引述文字",
|
||
SlashActionKind::CodeBlock => "已切到代码块",
|
||
SlashActionKind::Divider => "已插入分割线",
|
||
})
|
||
.map_err(|err| format!("命令执行失败:{err}"))
|
||
}
|
||
|
||
fn run_block_turn_into_action(
|
||
editor: TiptapEditorHandle,
|
||
block_index: usize,
|
||
action: SlashActionKind,
|
||
) -> Result<&'static str, String> {
|
||
let document = editor
|
||
.get_json()
|
||
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
|
||
let range = top_level_block_range(&document, block_index)
|
||
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;
|
||
|
||
match action {
|
||
SlashActionKind::Divider => editor
|
||
.insert_content_at(
|
||
range,
|
||
TiptapContent::json(json!({ "type": "horizontalRule" })),
|
||
None,
|
||
)
|
||
.map(|_| "已把当前块转成分割线")
|
||
.map_err(|err| format!("命令执行失败:{err}")),
|
||
_ => {
|
||
editor
|
||
.set_text_selection(range)
|
||
.map_err(|err| format!("选中当前块失败:{err}"))?;
|
||
editor
|
||
.clear_nodes()
|
||
.map_err(|err| format!("清理当前块样式失败:{err}"))?;
|
||
|
||
let message = run_slash_action(editor, action)?;
|
||
let _ = editor.focus();
|
||
let _ = editor.select_textblock_end();
|
||
Ok(message)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn normalized_page_block_title(text: &str) -> String {
|
||
let trimmed = text.trim();
|
||
if trimmed.is_empty() {
|
||
"未命名页面".to_string()
|
||
} else {
|
||
trimmed.to_string()
|
||
}
|
||
}
|
||
|
||
fn block_plain_text_from_index(
|
||
editor: TiptapEditorHandle,
|
||
block_index: usize,
|
||
) -> Result<String, String> {
|
||
let document = editor
|
||
.get_json()
|
||
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
|
||
let block = document
|
||
.get("content")
|
||
.and_then(Value::as_array)
|
||
.and_then(|content| content.get(block_index))
|
||
.ok_or_else(|| format!("找不到第 {} 个块", block_index + 1))?;
|
||
Ok(normalized_page_block_title(&collect_plain_text(block)))
|
||
}
|
||
|
||
fn inline_has_page_block_link(node: &Value) -> bool {
|
||
if node
|
||
.get("marks")
|
||
.and_then(Value::as_array)
|
||
.map(|marks| {
|
||
marks.iter().any(|mark| {
|
||
mark.get("type").and_then(Value::as_str) == Some("link")
|
||
&& mark
|
||
.get("attrs")
|
||
.and_then(|attrs| attrs.get("class"))
|
||
.and_then(Value::as_str)
|
||
.map(|class_name| {
|
||
class_name
|
||
.split_whitespace()
|
||
.any(|part| part == "mnote-page-block-link")
|
||
})
|
||
.unwrap_or(false)
|
||
})
|
||
})
|
||
.unwrap_or(false)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
node.get("content")
|
||
.and_then(Value::as_array)
|
||
.map(|children| children.iter().any(inline_has_page_block_link))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
fn top_level_block_is_page(editor: TiptapEditorHandle, block_index: usize) -> bool {
|
||
editor
|
||
.get_json()
|
||
.ok()
|
||
.and_then(|document| {
|
||
document
|
||
.get("content")
|
||
.and_then(Value::as_array)
|
||
.and_then(|content| content.get(block_index).cloned())
|
||
})
|
||
.map(|block| inline_has_page_block_link(&block))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
fn page_reference_paragraph_node(page_id: &str, title: &str) -> Value {
|
||
let href = if page_id.trim().is_empty() {
|
||
"#".to_string()
|
||
} else {
|
||
format!("/documents/{page_id}")
|
||
};
|
||
paragraph_node(vec![json!({
|
||
"type": "text",
|
||
"text": normalized_page_block_title(title),
|
||
"marks": [{
|
||
"type": "link",
|
||
"attrs": {
|
||
"href": href,
|
||
"target": null,
|
||
"rel": "noopener noreferrer nofollow",
|
||
"class": "mnote-page-block-link",
|
||
}
|
||
}]
|
||
})])
|
||
}
|
||
|
||
fn replace_block_with_page_reference(
|
||
editor: TiptapEditorHandle,
|
||
block_index: usize,
|
||
page_id: &str,
|
||
title: &str,
|
||
) -> Result<(), String> {
|
||
let mut document = editor
|
||
.get_json()
|
||
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
|
||
let content = document_content_mut(&mut document)?;
|
||
if block_index >= content.len() {
|
||
return Err(format!("找不到第 {} 个块", block_index + 1));
|
||
}
|
||
content[block_index] = page_reference_paragraph_node(page_id, title);
|
||
let next_payload = document
|
||
.get("content")
|
||
.and_then(Value::as_array)
|
||
.cloned()
|
||
.map(Value::Array)
|
||
.unwrap_or(document);
|
||
editor
|
||
.set_content(TiptapContent::json(next_payload))
|
||
.map_err(|err| format!("转换为页面失败:{err}"))?;
|
||
focus_top_level_block_start(editor, block_index)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn run_block_turn_into_page_action(
|
||
editor: TiptapEditorHandle,
|
||
block_index: usize,
|
||
document_id: ReadSignal<Option<String>>,
|
||
workspace_id: ReadSignal<Option<String>>,
|
||
title: ReadSignal<String>,
|
||
set_dirty_count: WriteSignal<u32>,
|
||
set_html_output: WriteSignal<String>,
|
||
set_document_json: WriteSignal<Value>,
|
||
set_json_output: WriteSignal<String>,
|
||
set_command_feedback: WriteSignal<String>,
|
||
set_block_menu_open: WriteSignal<bool>,
|
||
set_block_menu_anchor: WriteSignal<Option<HoveredBlockState>>,
|
||
set_block_turn_into_open: WriteSignal<bool>,
|
||
) {
|
||
let page_title = match block_plain_text_from_index(editor, block_index) {
|
||
Ok(value) => value,
|
||
Err(err) => {
|
||
set_command_feedback.set(err);
|
||
return;
|
||
}
|
||
};
|
||
let page_id = document_id
|
||
.get_untracked()
|
||
.unwrap_or_else(|| format!("page-block-{}", block_index + 1));
|
||
match replace_block_with_page_reference(editor, block_index, &page_id, &page_title) {
|
||
Ok(()) => {
|
||
set_block_menu_open.set(false);
|
||
set_block_menu_anchor.set(None);
|
||
set_block_turn_into_open.set(false);
|
||
sync_persisted_editor_command(
|
||
editor,
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
title,
|
||
set_command_feedback,
|
||
format!("已转换为页面:{page_title}"),
|
||
);
|
||
}
|
||
Err(err) => set_command_feedback.set(err),
|
||
}
|
||
}
|
||
|
||
fn apply_feedback_result<E>(setter: WriteSignal<String>, result: Result<&'static str, E>)
|
||
where
|
||
E: Display,
|
||
{
|
||
match result {
|
||
Ok(message) => setter.set(message.to_string()),
|
||
Err(err) => setter.set(format!("命令执行失败:{err}")),
|
||
}
|
||
}
|
||
|
||
fn apply_ok_feedback<E>(
|
||
setter: WriteSignal<String>,
|
||
result: Result<(), E>,
|
||
ok_message: &'static str,
|
||
) where
|
||
E: Display,
|
||
{
|
||
apply_feedback_result(setter, result.map(|_| ok_message));
|
||
}
|
||
|
||
fn normalize_layout_density(value: Option<String>) -> String {
|
||
match value.as_deref() {
|
||
Some("compact") => "compact".to_string(),
|
||
Some("spacious") => "spacious".to_string(),
|
||
_ => "normal".to_string(),
|
||
}
|
||
}
|
||
|
||
#[component]
|
||
fn App(mount_options: MountOptions) -> impl IntoView {
|
||
let editor = TiptapEditorHandle::new();
|
||
let initial_document_id = mount_options
|
||
.document_id
|
||
.clone()
|
||
.or_else(current_document_id);
|
||
let initial_workspace_id = mount_options
|
||
.workspace_id
|
||
.clone()
|
||
.or_else(current_workspace_id);
|
||
let persisted_identity = resolve_persisted_document_identity(
|
||
initial_document_id.clone(),
|
||
initial_workspace_id.clone(),
|
||
);
|
||
let persisted = load_persisted_document(&persisted_identity);
|
||
let restored_from_storage = persisted.is_some();
|
||
let restored_html = persisted
|
||
.as_ref()
|
||
.and_then(|document| document.html.clone());
|
||
let initial_title_value = mount_options
|
||
.title
|
||
.clone()
|
||
.or_else(|| persisted.as_ref().map(|document| document.title.clone()))
|
||
.unwrap_or_else(default_title);
|
||
let initial_editor_content = mount_options
|
||
.html
|
||
.clone()
|
||
.map(TiptapContent::html)
|
||
.or_else(|| mount_options.content.clone().map(TiptapContent::json))
|
||
.or_else(|| {
|
||
persisted.as_ref().map(|document| {
|
||
document
|
||
.html
|
||
.clone()
|
||
.map(TiptapContent::html)
|
||
.unwrap_or_else(|| TiptapContent::json(document.content.clone()))
|
||
})
|
||
})
|
||
.unwrap_or_else(initial_content);
|
||
|
||
let (document_id, set_document_id) = signal(initial_document_id);
|
||
let (workspace_id, set_workspace_id) = signal(initial_workspace_id);
|
||
let (title, set_title) = signal(initial_title_value);
|
||
let (read_only, set_read_only) = signal(mount_options.read_only.unwrap_or(false));
|
||
let (revision, set_revision) = signal(mount_options.revision);
|
||
let (conflict_detection_key, set_conflict_detection_key) =
|
||
signal(mount_options.conflict_detection_key.clone());
|
||
let (html_output, set_html_output) = signal(String::new());
|
||
let (document_json, set_document_json) = signal(
|
||
persisted
|
||
.as_ref()
|
||
.map(|document| document.content.clone())
|
||
.unwrap_or_else(|| json!({"type": "doc", "content": []})),
|
||
);
|
||
let (json_output, set_json_output) = signal(String::new());
|
||
let (selection_state, set_selection_state) = signal(TiptapSelectionState::default());
|
||
let (dirty_count, set_dirty_count) = signal(0_u32);
|
||
let (editor_focused, set_editor_focused) = signal(false);
|
||
let (text_selection_active, set_text_selection_active) = signal(false);
|
||
let (floating_toolbar_anchor, set_floating_toolbar_anchor) =
|
||
signal(None::<FloatingToolbarAnchor>);
|
||
let (locked_toolbar_anchor, set_locked_toolbar_anchor) = signal(None::<FloatingToolbarAnchor>);
|
||
let (slash_open, set_slash_open) = signal(false);
|
||
let (slash_index, set_slash_index) = signal(0_usize);
|
||
let (turn_into_open, set_turn_into_open) = signal(false);
|
||
let (color_menu_open, set_color_menu_open) = signal(false);
|
||
let (more_menu_open, set_more_menu_open) = signal(false);
|
||
let (hovered_block, set_hovered_block) = signal(None::<HoveredBlockState>);
|
||
let (block_keyboard_mode, set_block_keyboard_mode) = signal(false);
|
||
let (block_menu_anchor, set_block_menu_anchor) = signal(None::<HoveredBlockState>);
|
||
let (block_menu_open, set_block_menu_open) = signal(false);
|
||
let (block_turn_into_open, set_block_turn_into_open) = signal(false);
|
||
let (pending_drag, set_pending_drag) = signal(None::<PendingDragState>);
|
||
let (dragging_block_index, set_dragging_block_index) = signal(None::<usize>);
|
||
let (dragging_block_anchor, set_dragging_block_anchor) = signal(None::<HoveredBlockState>);
|
||
let (drop_indicator, set_drop_indicator) = signal(None::<DropIndicatorState>);
|
||
let (suppress_handle_click, set_suppress_handle_click) = signal(false);
|
||
let (command_feedback, set_command_feedback) = signal("等待第一次编辑".to_string());
|
||
let initial_editable = mount_options.editable.unwrap_or(!read_only.get_untracked());
|
||
let (editor_editable, set_editor_editable) = signal(initial_editable);
|
||
let initial_page_options = mount_options.page_options.clone();
|
||
let (wide_layout, set_wide_layout) = signal(
|
||
initial_page_options
|
||
.as_ref()
|
||
.and_then(|opts| opts.wide_layout)
|
||
.unwrap_or(false),
|
||
);
|
||
let (small_text, set_small_text) = signal(
|
||
initial_page_options
|
||
.as_ref()
|
||
.and_then(|opts| opts.small_text)
|
||
.unwrap_or(false),
|
||
);
|
||
let (layout_density, set_layout_density) = signal(normalize_layout_density(
|
||
initial_page_options
|
||
.as_ref()
|
||
.and_then(|opts| opts.layout_density.clone()),
|
||
));
|
||
let (show_heading_numbers, set_show_heading_numbers) = signal(
|
||
initial_page_options
|
||
.as_ref()
|
||
.and_then(|opts| opts.show_heading_numbers)
|
||
.unwrap_or(true),
|
||
);
|
||
let (embed_default_block_id, set_embed_default_block_id) = signal(
|
||
initial_page_options
|
||
.as_ref()
|
||
.and_then(|opts| opts.embed_default_block_id.clone()),
|
||
);
|
||
|
||
{
|
||
let block_menu_open = block_menu_open;
|
||
Effect::new(move |_| {
|
||
let Some(document) = window().and_then(|win| win.document()) else {
|
||
return;
|
||
};
|
||
let Some(body) = document.body() else {
|
||
return;
|
||
};
|
||
|
||
if block_menu_open.get() {
|
||
let _ = body.style().set_property("overflow", "hidden");
|
||
} else {
|
||
let _ = body.style().remove_property("overflow");
|
||
}
|
||
});
|
||
}
|
||
|
||
{
|
||
Effect::new(move |_| {
|
||
let Some(stage) = editor_stage_element() else {
|
||
return;
|
||
};
|
||
let height = stage.get_bounding_client_rect().height();
|
||
if height > 0.0 {
|
||
dispatch_runtime_event(HEIGHT_EVENT, &HeightPayload { height });
|
||
}
|
||
});
|
||
}
|
||
|
||
{
|
||
let block_menu_open = block_menu_open;
|
||
let set_block_turn_into_open = set_block_turn_into_open;
|
||
Effect::new(move |_| {
|
||
if !block_menu_open.get() {
|
||
set_block_turn_into_open.set(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
Effect::new({
|
||
let editor = editor;
|
||
let set_document_id = set_document_id;
|
||
let set_workspace_id = set_workspace_id;
|
||
let set_title = set_title;
|
||
let set_read_only = set_read_only;
|
||
let set_revision = set_revision;
|
||
let set_conflict_detection_key = set_conflict_detection_key;
|
||
let set_dirty_count = set_dirty_count;
|
||
let set_html_output = set_html_output;
|
||
let set_document_json = set_document_json;
|
||
let set_json_output = set_json_output;
|
||
let set_command_feedback = set_command_feedback;
|
||
let set_editor_editable = set_editor_editable;
|
||
let set_wide_layout = set_wide_layout;
|
||
let set_small_text = set_small_text;
|
||
let set_layout_density = set_layout_density;
|
||
let set_show_heading_numbers = set_show_heading_numbers;
|
||
let set_embed_default_block_id = set_embed_default_block_id;
|
||
move |_| {
|
||
let command_listener =
|
||
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
|
||
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
|
||
return;
|
||
};
|
||
let detail = custom_event.detail();
|
||
let Ok(envelope) =
|
||
serde_wasm_bindgen::from_value::<HostCommandEnvelope>(detail)
|
||
else {
|
||
return;
|
||
};
|
||
if envelope.protocol.as_deref() != Some(PROTOCOL) {
|
||
return;
|
||
}
|
||
|
||
let Some(command_kind) = HostCommandKind::from_payload(
|
||
envelope.payload.as_ref().unwrap_or(&HostCommandPayload {
|
||
command: None,
|
||
document_id: None,
|
||
workspace_id: None,
|
||
title: None,
|
||
content: None,
|
||
editable: None,
|
||
page_options: None,
|
||
block_id: None,
|
||
block_index: None,
|
||
text: None,
|
||
reference_document_id: None,
|
||
reference_block_id: None,
|
||
current_block_id: None,
|
||
selection: None,
|
||
revision: None,
|
||
conflict_detection_key: None,
|
||
read_only: None,
|
||
}),
|
||
) else {
|
||
return;
|
||
};
|
||
|
||
let Some(payload) = envelope.payload else {
|
||
return;
|
||
};
|
||
|
||
match command_kind {
|
||
HostCommandKind::Undo => {
|
||
let _ = editor.undo();
|
||
}
|
||
HostCommandKind::Redo => {
|
||
let _ = editor.redo();
|
||
}
|
||
HostCommandKind::ReplaceContent | HostCommandKind::Bootstrap => {
|
||
apply_host_document_payload(
|
||
editor,
|
||
HostDocumentPayload {
|
||
document_id: payload.document_id,
|
||
workspace_id: payload.workspace_id,
|
||
title: payload.title,
|
||
content: payload.content,
|
||
revision: payload.revision,
|
||
conflict_detection_key: payload.conflict_detection_key,
|
||
read_only: payload
|
||
.read_only
|
||
.or(payload.editable.map(|editable| !editable)),
|
||
},
|
||
set_document_id,
|
||
set_workspace_id,
|
||
set_title,
|
||
set_read_only,
|
||
set_revision,
|
||
set_conflict_detection_key,
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
set_command_feedback,
|
||
);
|
||
}
|
||
HostCommandKind::SetEditable => {
|
||
let editable = payload.editable.unwrap_or(true);
|
||
set_editor_editable.set(editable);
|
||
set_read_only.set(!editable);
|
||
dispatch_runtime_state(
|
||
document_id.get_untracked(),
|
||
workspace_id.get_untracked(),
|
||
title.get_untracked(),
|
||
dirty_count.get_untracked(),
|
||
hovered_block.get_untracked(),
|
||
editor_focused.get_untracked(),
|
||
slash_open.get_untracked(),
|
||
turn_into_open.get_untracked(),
|
||
color_menu_open.get_untracked(),
|
||
more_menu_open.get_untracked(),
|
||
read_only.get_untracked(),
|
||
);
|
||
}
|
||
HostCommandKind::SetPageOptions => {
|
||
if let Some(page_options) = payload.page_options {
|
||
if let Some(value) = page_options.wide_layout {
|
||
set_wide_layout.set(value);
|
||
}
|
||
if let Some(value) = page_options.small_text {
|
||
set_small_text.set(value);
|
||
}
|
||
set_layout_density
|
||
.set(normalize_layout_density(page_options.layout_density));
|
||
if let Some(value) = page_options.show_heading_numbers {
|
||
set_show_heading_numbers.set(value);
|
||
}
|
||
set_embed_default_block_id.set(page_options.embed_default_block_id);
|
||
set_command_feedback.set("页面布局选项已同步".to_string());
|
||
}
|
||
}
|
||
HostCommandKind::Focus => {
|
||
let _ = editor.focus();
|
||
}
|
||
HostCommandKind::RequestCurrentBlockId => {
|
||
let current_block = current_block_info_from_index(
|
||
hovered_block.get_untracked().map(|block| block.index),
|
||
);
|
||
dispatch_status_event(&HostStatusPayload {
|
||
document_id: document_id.get_untracked(),
|
||
workspace_id: workspace_id.get_untracked(),
|
||
title: title.get_untracked(),
|
||
dirty_count: dirty_count.get_untracked(),
|
||
selected_block_index: current_block.index,
|
||
current_block_id: current_block.block_id,
|
||
editor_focused: editor_focused.get_untracked(),
|
||
read_only: read_only.get_untracked(),
|
||
slash_open: slash_open.get_untracked(),
|
||
toolbar_open: toolbar_overlay_locked(
|
||
turn_into_open.get_untracked(),
|
||
color_menu_open.get_untracked(),
|
||
more_menu_open.get_untracked(),
|
||
),
|
||
});
|
||
}
|
||
HostCommandKind::InsertInlineReference
|
||
| HostCommandKind::InsertEmbedReference => {
|
||
let reference_text = match (
|
||
payload.reference_document_id.or(payload.document_id),
|
||
payload.reference_block_id.or(payload.block_id),
|
||
) {
|
||
(Some(document_id), Some(block_id)) => {
|
||
format!("[[引用 {}:{}]]", document_id, block_id)
|
||
}
|
||
(Some(document_id), None) => format!("[[引用 {}]]", document_id),
|
||
_ => "[[引用]]".to_string(),
|
||
};
|
||
let node =
|
||
if matches!(command_kind, HostCommandKind::InsertEmbedReference) {
|
||
json!({
|
||
"type": "blockquote",
|
||
"content": [
|
||
{
|
||
"type": "paragraph",
|
||
"content": [
|
||
{ "type": "text", "text": reference_text }
|
||
]
|
||
}
|
||
]
|
||
})
|
||
} else {
|
||
json!({
|
||
"type": "paragraph",
|
||
"content": [
|
||
{ "type": "text", "text": reference_text }
|
||
]
|
||
})
|
||
};
|
||
let _ = editor.insert_content(TiptapContent::json(node), None);
|
||
}
|
||
}
|
||
}));
|
||
|
||
if let Some(document) = window().and_then(|win| win.document()) {
|
||
let listener_ref = command_listener.as_ref().unchecked_ref();
|
||
let _ = document.add_event_listener_with_callback(COMMAND_EVENT, listener_ref);
|
||
if let Some((mount_id, _, _)) = runtime_mount_context() {
|
||
let target: EventTarget = document.into();
|
||
register_runtime_listener(mount_id, target, command_listener);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
{
|
||
let turn_into_open = turn_into_open;
|
||
let color_menu_open = color_menu_open;
|
||
let more_menu_open = more_menu_open;
|
||
let set_locked_toolbar_anchor = set_locked_toolbar_anchor;
|
||
Effect::new(move |_| {
|
||
if !(turn_into_open.get() || color_menu_open.get() || more_menu_open.get()) {
|
||
set_locked_toolbar_anchor.set(None);
|
||
}
|
||
});
|
||
}
|
||
|
||
{
|
||
let editor_focused = editor_focused;
|
||
let text_selection_active = text_selection_active;
|
||
let slash_open = slash_open;
|
||
let block_menu_open = block_menu_open;
|
||
let turn_into_open = turn_into_open;
|
||
let color_menu_open = color_menu_open;
|
||
let more_menu_open = more_menu_open;
|
||
let set_turn_into_open = set_turn_into_open;
|
||
let set_color_menu_open = set_color_menu_open;
|
||
let set_more_menu_open = set_more_menu_open;
|
||
Effect::new(move |_| {
|
||
let toolbar_locked = toolbar_overlay_locked(
|
||
turn_into_open.get_untracked(),
|
||
color_menu_open.get_untracked(),
|
||
more_menu_open.get_untracked(),
|
||
);
|
||
if ((!editor_focused.get() || !text_selection_active.get()) && !toolbar_locked)
|
||
|| slash_open.get()
|
||
|| block_menu_open.get()
|
||
{
|
||
set_turn_into_open.set(false);
|
||
set_color_menu_open.set(false);
|
||
set_more_menu_open.set(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
{
|
||
let set_editor_focused = set_editor_focused;
|
||
let set_text_selection_active = set_text_selection_active;
|
||
let set_floating_toolbar_anchor = set_floating_toolbar_anchor;
|
||
let set_slash_open = set_slash_open;
|
||
let set_turn_into_open = set_turn_into_open;
|
||
let set_hovered_block = set_hovered_block;
|
||
let set_block_menu_anchor = set_block_menu_anchor;
|
||
let set_block_menu_open = set_block_menu_open;
|
||
let set_pending_drag = set_pending_drag;
|
||
let set_dragging_block_index = set_dragging_block_index;
|
||
let set_dragging_block_anchor = set_dragging_block_anchor;
|
||
let set_drop_indicator = set_drop_indicator;
|
||
let set_command_feedback = set_command_feedback;
|
||
let set_suppress_handle_click = set_suppress_handle_click;
|
||
let pending_drag = pending_drag;
|
||
let dragging_block_index = dragging_block_index;
|
||
let mouseup_handle = window_event_listener(ev::mouseup, move |event: MouseEvent| {
|
||
if let Some(source_index) = dragging_block_index.try_get_untracked().flatten() {
|
||
let indicator = drop_indicator_from_point(event.client_x(), event.client_y());
|
||
if let Some(indicator) = indicator {
|
||
match editor.get_html() {
|
||
Ok(current_html) => match reorder_top_level_block_html(
|
||
¤t_html,
|
||
source_index,
|
||
indicator.index,
|
||
indicator.placement,
|
||
) {
|
||
Ok(next_html) => {
|
||
apply_html_update(
|
||
editor,
|
||
&persisted_identity,
|
||
next_html,
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
title,
|
||
set_command_feedback,
|
||
"已通过块手柄拖拽重排",
|
||
);
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(err);
|
||
}
|
||
},
|
||
Err(err) => {
|
||
let _ =
|
||
set_command_feedback.try_set(format!("读取当前 HTML 失败:{err}"));
|
||
}
|
||
}
|
||
} else {
|
||
let _ = set_command_feedback.try_set("块拖拽已取消".to_string());
|
||
}
|
||
let _ = set_dragging_block_index.try_set(None);
|
||
let _ = set_dragging_block_anchor.try_set(None);
|
||
let _ = set_drop_indicator.try_set(None);
|
||
let _ = set_pending_drag.try_set(None);
|
||
let _ = set_block_menu_open.try_set(false);
|
||
let _ = set_block_menu_anchor.try_set(None);
|
||
let _ = set_hovered_block.try_set(None);
|
||
let _ = set_suppress_handle_click.try_set(true);
|
||
} else if pending_drag.try_get_untracked().flatten().is_some() {
|
||
let _ = set_pending_drag.try_set(None);
|
||
}
|
||
|
||
if let Some(target) = event.target() {
|
||
if let Some(element) = target_element(target) {
|
||
if element
|
||
.closest(&format!("{HANDLE_SHELL_SELECTOR}, .floating-toolbar"))
|
||
.ok()
|
||
.flatten()
|
||
.is_some()
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
let _ = try_sync_editor_overlay_state(
|
||
set_editor_focused,
|
||
set_text_selection_active,
|
||
set_floating_toolbar_anchor,
|
||
set_slash_open,
|
||
set_turn_into_open,
|
||
set_hovered_block,
|
||
set_block_menu_anchor,
|
||
set_block_menu_open,
|
||
);
|
||
});
|
||
let keyup_handle = window_event_listener(ev::keyup, move |_| {
|
||
let _ = try_sync_editor_overlay_state(
|
||
set_editor_focused,
|
||
set_text_selection_active,
|
||
set_floating_toolbar_anchor,
|
||
set_slash_open,
|
||
set_turn_into_open,
|
||
set_hovered_block,
|
||
set_block_menu_anchor,
|
||
set_block_menu_open,
|
||
);
|
||
});
|
||
|
||
on_cleanup(move || {
|
||
drop(mouseup_handle);
|
||
drop(keyup_handle);
|
||
});
|
||
}
|
||
|
||
{
|
||
let set_editor_focused = set_editor_focused;
|
||
let set_slash_open = set_slash_open;
|
||
let set_turn_into_open = set_turn_into_open;
|
||
let set_hovered_block = set_hovered_block;
|
||
let set_block_menu_anchor = set_block_menu_anchor;
|
||
let set_block_menu_open = set_block_menu_open;
|
||
let set_pending_drag = set_pending_drag;
|
||
let set_dragging_block_index = set_dragging_block_index;
|
||
let set_dragging_block_anchor = set_dragging_block_anchor;
|
||
let set_drop_indicator = set_drop_indicator;
|
||
let set_command_feedback = set_command_feedback;
|
||
let pending_drag = pending_drag;
|
||
let dragging_block_index = dragging_block_index;
|
||
let dragging_block_anchor = dragging_block_anchor;
|
||
let mousemove_handle = window_event_listener(ev::mousemove, move |event: MouseEvent| {
|
||
if dragging_block_index.try_get_untracked().flatten().is_some() {
|
||
let _ = set_editor_focused.try_set(true);
|
||
let _ = set_drop_indicator.try_set(drop_indicator_from_point(
|
||
event.client_x(),
|
||
event.client_y(),
|
||
));
|
||
return;
|
||
}
|
||
|
||
let Some(pending) = pending_drag.try_get_untracked().flatten() else {
|
||
return;
|
||
};
|
||
|
||
if event.buttons() & 1 == 0 {
|
||
let _ = set_pending_drag.try_set(None);
|
||
return;
|
||
}
|
||
|
||
let dx = (event.client_x() - pending.start_x).abs();
|
||
let dy = (event.client_y() - pending.start_y).abs();
|
||
if dx < 4 && dy < 4 {
|
||
return;
|
||
}
|
||
|
||
let _ = set_editor_focused.try_set(true);
|
||
let _ = set_slash_open.try_set(false);
|
||
let _ = set_turn_into_open.try_set(false);
|
||
let _ = set_block_menu_open.try_set(false);
|
||
let _ = set_block_menu_anchor.try_set(None);
|
||
let _ = set_hovered_block.try_set(Some(pending.anchor.clone()));
|
||
let _ = set_dragging_block_index.try_set(Some(pending.index));
|
||
let next_anchor = dragging_block_anchor
|
||
.try_get_untracked()
|
||
.flatten()
|
||
.unwrap_or_else(|| pending.anchor.clone());
|
||
let _ = set_dragging_block_anchor.try_set(Some(next_anchor));
|
||
let _ = set_pending_drag.try_set(None);
|
||
let _ = set_drop_indicator.try_set(drop_indicator_from_point(
|
||
event.client_x(),
|
||
event.client_y(),
|
||
));
|
||
let _ = set_command_feedback.try_set(format!("开始拖拽:{}", pending.anchor.label));
|
||
});
|
||
|
||
on_cleanup(move || drop(mousemove_handle));
|
||
}
|
||
|
||
{
|
||
let editor = editor;
|
||
let document_id = document_id;
|
||
let workspace_id = workspace_id;
|
||
let title = title;
|
||
let set_dirty_count = set_dirty_count;
|
||
let set_html_output = set_html_output;
|
||
let set_document_json = set_document_json;
|
||
let set_json_output = set_json_output;
|
||
let hovered_block = hovered_block;
|
||
let set_hovered_block = set_hovered_block;
|
||
let block_keyboard_mode = block_keyboard_mode;
|
||
let set_block_keyboard_mode = set_block_keyboard_mode;
|
||
let set_slash_open = set_slash_open;
|
||
let slash_open = slash_open;
|
||
let slash_index = slash_index;
|
||
let set_slash_index = set_slash_index;
|
||
let set_turn_into_open = set_turn_into_open;
|
||
let set_block_menu_open = set_block_menu_open;
|
||
let block_menu_open = block_menu_open;
|
||
let set_block_menu_anchor = set_block_menu_anchor;
|
||
let set_command_feedback = set_command_feedback;
|
||
let keydown_handle = window_event_listener(ev::keydown, move |event| {
|
||
let focused = active_editor_stage();
|
||
if !focused {
|
||
return;
|
||
}
|
||
|
||
if event.key() == "Escape" {
|
||
event.prevent_default();
|
||
let overlay_open = slash_open.try_get_untracked().unwrap_or(false)
|
||
|| block_menu_open.try_get_untracked().unwrap_or(false);
|
||
let _ = set_slash_open.try_set(false);
|
||
let _ = set_turn_into_open.try_set(false);
|
||
let _ = set_block_menu_open.try_set(false);
|
||
let _ = set_block_menu_anchor.try_set(None);
|
||
if overlay_open {
|
||
let _ = set_block_keyboard_mode.try_set(false);
|
||
return;
|
||
}
|
||
|
||
if let Some(block) = hovered_block_from_selection()
|
||
.or_else(|| hovered_block.try_get_untracked().flatten())
|
||
.or_else(|| block_state_from_index(0))
|
||
{
|
||
let _ = set_hovered_block.try_set(Some(block.clone()));
|
||
let _ = set_block_keyboard_mode.try_set(true);
|
||
let _ = set_command_feedback
|
||
.try_set(format!("已选中块:{},按 a/b 可在上/下插入", block.label));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if block_keyboard_mode.try_get_untracked().unwrap_or(false)
|
||
&& !event.ctrl_key()
|
||
&& !event.meta_key()
|
||
&& !event.alt_key()
|
||
&& matches!(event.key().as_str(), "a" | "A" | "b" | "B")
|
||
{
|
||
event.prevent_default();
|
||
let before = matches!(event.key().as_str(), "a" | "A");
|
||
let Some(block) = hovered_block
|
||
.try_get_untracked()
|
||
.flatten()
|
||
.or_else(|| hovered_block_from_selection())
|
||
.or_else(|| block_state_from_index(0))
|
||
else {
|
||
let _ = set_command_feedback.try_set("没有可插入的当前块".to_string());
|
||
return;
|
||
};
|
||
match insert_editor_paragraph_relative_to_block(editor, block.index, before) {
|
||
Ok(new_index) => {
|
||
sync_persisted_editor_command(
|
||
editor,
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
title,
|
||
set_command_feedback,
|
||
if before {
|
||
"已通过 Esc,a 在上方插入新块"
|
||
} else {
|
||
"已通过 Esc,b 在下方插入新块"
|
||
},
|
||
);
|
||
let _ = set_hovered_block.try_set(block_state_from_index(new_index));
|
||
let _ = set_block_keyboard_mode.try_set(false);
|
||
}
|
||
Err(err) => {
|
||
let _ = set_command_feedback.try_set(err);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if event.key() == "/" && !event.ctrl_key() && !event.meta_key() && !event.alt_key() {
|
||
event.prevent_default();
|
||
let _ = set_slash_open.try_set(true);
|
||
let _ = set_turn_into_open.try_set(false);
|
||
let _ = set_block_menu_open.try_set(false);
|
||
let _ = set_block_menu_anchor.try_set(None);
|
||
let _ = set_block_keyboard_mode.try_set(false);
|
||
let _ = set_slash_index.try_set(0);
|
||
let _ = set_command_feedback.try_set("Slash 菜单已打开".to_string());
|
||
return;
|
||
}
|
||
|
||
if !slash_open.try_get_untracked().unwrap_or(false) {
|
||
return;
|
||
}
|
||
|
||
match event.key().as_str() {
|
||
"ArrowDown" => {
|
||
event.prevent_default();
|
||
let next =
|
||
(slash_index.try_get_untracked().unwrap_or(0) + 1) % SLASH_ACTIONS.len();
|
||
let _ = set_slash_index.try_set(next);
|
||
}
|
||
"ArrowUp" => {
|
||
event.prevent_default();
|
||
let current = slash_index.try_get_untracked().unwrap_or(0);
|
||
let next = if current == 0 {
|
||
SLASH_ACTIONS.len() - 1
|
||
} else {
|
||
current - 1
|
||
};
|
||
let _ = set_slash_index.try_set(next);
|
||
}
|
||
"Enter" => {
|
||
event.prevent_default();
|
||
let action = SLASH_ACTIONS[slash_index.try_get_untracked().unwrap_or(0)].kind;
|
||
match run_slash_action(editor, action) {
|
||
Ok(message) => {
|
||
let _ = set_command_feedback.try_set(message.to_string());
|
||
}
|
||
Err(message) => {
|
||
let _ = set_command_feedback.try_set(message);
|
||
}
|
||
}
|
||
let _ = set_slash_open.try_set(false);
|
||
}
|
||
_ => {}
|
||
}
|
||
});
|
||
on_cleanup(move || drop(keydown_handle));
|
||
}
|
||
|
||
let has_task_schema = move || {
|
||
let snapshot = json_output.get();
|
||
snapshot.contains("\"taskList\"") && snapshot.contains("\"taskItem\"")
|
||
};
|
||
|
||
let selection_text = move || selection_summary(&selection_state.get());
|
||
let is_embedded = is_embedded_mode();
|
||
|
||
let on_link_click = {
|
||
let editor = editor;
|
||
let selection_state = selection_state;
|
||
let set_command_feedback = set_command_feedback;
|
||
move |_| {
|
||
if selection_state.get().link {
|
||
apply_feedback_result(
|
||
set_command_feedback,
|
||
editor.unset_link().map(|_| "已移除链接"),
|
||
);
|
||
return;
|
||
}
|
||
|
||
let href = window()
|
||
.and_then(|win| win.prompt_with_message("输入链接地址").ok().flatten())
|
||
.filter(|value| !value.trim().is_empty());
|
||
|
||
match href {
|
||
Some(value) => apply_feedback_result(
|
||
set_command_feedback,
|
||
editor
|
||
.set_link(TiptapLinkResource {
|
||
href: value,
|
||
target: Some("_blank".into()),
|
||
rel: Some("noopener noreferrer".into()),
|
||
class: None,
|
||
})
|
||
.map(|_| "已插入链接"),
|
||
),
|
||
None => set_command_feedback.set("链接操作已取消".to_string()),
|
||
}
|
||
}
|
||
};
|
||
|
||
view! {
|
||
<style>{SPIKE_STYLE}</style>
|
||
<main
|
||
class=move || if is_embedded { "app-shell app-shell-embedded" } else { "app-shell" }
|
||
data-testid="mnote-leptos-tiptap-host"
|
||
data-mnote-runtime=RUNTIME_NAME
|
||
data-mnote-runtime-bridge="true"
|
||
>
|
||
{move || {
|
||
if is_embedded {
|
||
().into_any()
|
||
} else {
|
||
view! {
|
||
<section class="hero-bar">
|
||
<div class="hero-meta">
|
||
<span class="hero-tag">"P0 / Tiptap-like 主编辑器体验"</span>
|
||
<h1 class="hero-title">"把 Spike 收敛成能检验的最小产品页壳"</h1>
|
||
<p class="hero-copy">
|
||
"这里继续只做 "
|
||
<code>"8123"</code>
|
||
" 的 Leptos + Tiptap P0 体验验证,不挪动正式 "
|
||
<code>"/documents/[id]"</code>
|
||
" 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。"
|
||
</p>
|
||
</div>
|
||
<div class="hero-actions">
|
||
<div class="hero-chip">
|
||
<span>"变更计数"</span>
|
||
<strong>{move || dirty_count.get().to_string()}</strong>
|
||
</div>
|
||
<div class="hero-chip" id="todo-schema-status">
|
||
<span>"Todo Schema"</span>
|
||
<strong>{move || if has_task_schema() { "ready" } else { "pending" }}</strong>
|
||
</div>
|
||
<div class="hero-chip" id="selection-summary-chip">
|
||
<span>"Selection"</span>
|
||
<strong>{selection_text}</strong>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
}.into_any()
|
||
}
|
||
}}
|
||
|
||
<section class="editor-card">
|
||
{move || {
|
||
if is_embedded {
|
||
().into_any()
|
||
} else {
|
||
view! {
|
||
<>
|
||
<div class="editor-topbar">
|
||
<div class="editor-crumbs">
|
||
<span>"空间 / 主文档 /"</span>
|
||
<strong>"Editor Baseline Reset"</strong>
|
||
</div>
|
||
<div class="editor-crumbs">
|
||
<span>"反馈:"</span>
|
||
<strong id="command-feedback">{move || command_feedback.get()}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<input
|
||
class="title-input"
|
||
prop:value=move || title.get()
|
||
on:input=move |event| {
|
||
let next_title = event_target_value(&event);
|
||
set_title.set(next_title.clone());
|
||
match persist_document_state(
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
&next_title,
|
||
&document_json.get_untracked(),
|
||
Some(html_output.get_untracked()),
|
||
) {
|
||
Ok(()) => set_command_feedback.set("标题已写入本地草稿".to_string()),
|
||
Err(err) => set_command_feedback.set(format!("标题保存失败:{err}")),
|
||
}
|
||
}
|
||
placeholder="给这篇页面起个标题"
|
||
aria-label="Spike 页面标题"
|
||
/>
|
||
|
||
<div class="editor-subcopy">
|
||
<span class="hint-pill">"输入 “/” 打开真正的最小 slash 菜单"</span>
|
||
<span class="hint-pill">"选中文本时会出现最小浮动工具条"</span>
|
||
<span class="hint-pill">"Todo 现在走 taskList / taskItem,而不是占位 HTML"</span>
|
||
<span class="hint-pill">"把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排"</span>
|
||
<span class="hint-pill">"本页会把 JSON 草稿写入 localStorage,刷新后不丢结构"</span>
|
||
</div>
|
||
</>
|
||
}.into_any()
|
||
}
|
||
}}
|
||
|
||
<div
|
||
id="editor-stage"
|
||
class="editor-stage"
|
||
data-testid="mnote-leptos-tiptap-editor-stage"
|
||
data-page-wide-layout=move || wide_layout.get().to_string()
|
||
data-page-show-heading-numbers=move || show_heading_numbers.get().to_string()
|
||
data-page-embed-default-block-id=move || embed_default_block_id.get().unwrap_or_default()
|
||
on:click=move |event: MouseEvent| {
|
||
let Some(target) = event.target() else {
|
||
return;
|
||
};
|
||
|
||
if let Some(element) = target_element(target.clone()) {
|
||
if element.closest(HANDLE_SHELL_SELECTOR).ok().flatten().is_some() {
|
||
return;
|
||
}
|
||
}
|
||
|
||
set_editor_focused.set(true);
|
||
set_block_keyboard_mode.set(false);
|
||
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:mouseover=move |event: MouseEvent| {
|
||
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
|
||
return;
|
||
}
|
||
let Some(target) = event.target() else {
|
||
return;
|
||
};
|
||
if let Some(block) = hovered_block_from_target(target) {
|
||
set_editor_focused.set(true);
|
||
set_hovered_block.set(Some(block));
|
||
}
|
||
}
|
||
on:mouseleave=move |event: MouseEvent| {
|
||
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
|
||
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: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,
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
next_html,
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
title,
|
||
set_command_feedback,
|
||
"已通过块手柄拖拽重排",
|
||
);
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(err);
|
||
}
|
||
},
|
||
Err(err) => {
|
||
set_command_feedback.set(format!("读取当前 HTML 失败:{err}"));
|
||
}
|
||
}
|
||
|
||
set_dragging_block_index.set(None);
|
||
set_dragging_block_anchor.set(None);
|
||
set_drop_indicator.set(None);
|
||
set_block_menu_open.set(false);
|
||
set_block_menu_anchor.set(None);
|
||
set_hovered_block.set(None);
|
||
}
|
||
>
|
||
{move || {
|
||
let toolbar_locked = toolbar_overlay_locked(
|
||
turn_into_open.get(),
|
||
color_menu_open.get(),
|
||
more_menu_open.get(),
|
||
);
|
||
let toolbar_anchor = if toolbar_locked {
|
||
locked_toolbar_anchor
|
||
.get()
|
||
.or_else(|| floating_toolbar_anchor.get())
|
||
} else {
|
||
floating_toolbar_anchor.get()
|
||
};
|
||
if let Some(anchor) = toolbar_anchor {
|
||
if ((!editor_focused.get() || !text_selection_active.get()) && !toolbar_locked)
|
||
|| slash_open.get()
|
||
|| block_menu_open.get()
|
||
|| dragging_block_index.get().is_some()
|
||
{
|
||
return ().into_any();
|
||
}
|
||
view! {
|
||
<div
|
||
class="floating-toolbar"
|
||
data-testid="mnote-leptos-tiptap-toolbar"
|
||
style:top=format!("{}px", anchor.top)
|
||
style:left=format!("{}px", anchor.left)
|
||
on:mousedown=move |event: MouseEvent| {
|
||
if event_target_matches_selector(
|
||
event.target(),
|
||
".toolbar-btn, .toolbar-menu-item, .turn-into-item",
|
||
) {
|
||
event.prevent_default();
|
||
}
|
||
event.stop_propagation();
|
||
}
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
}
|
||
>
|
||
<div class="floating-toolbar-group">
|
||
<div class="toolbar-popover-anchor">
|
||
<button
|
||
class="toolbar-btn"
|
||
data-wide="true"
|
||
data-active=move || turn_into_open.get().to_string()
|
||
data-testid="toolbar-turn-into"
|
||
on:click=move |_| {
|
||
let next_open = !turn_into_open.get_untracked();
|
||
if next_open {
|
||
set_locked_toolbar_anchor
|
||
.set(floating_toolbar_anchor.get_untracked());
|
||
}
|
||
set_turn_into_open.set(next_open);
|
||
set_block_menu_open.set(false);
|
||
}
|
||
>
|
||
"文本"
|
||
</button>
|
||
<div
|
||
class="turn-into-panel"
|
||
data-testid="turn-into-panel"
|
||
style:display=move || if turn_into_open.get() { "grid" } else { "none" }
|
||
on:mousedown=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
}
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
}
|
||
on:wheel=move |event: WheelEvent| {
|
||
trap_scroll_inside_menu(&event);
|
||
}
|
||
>
|
||
{SLASH_ACTIONS.iter().map(|action| {
|
||
let kind = action.kind;
|
||
let label = action.label;
|
||
let description = action.description;
|
||
let testid = format!("turn-into-{}", action.id);
|
||
view! {
|
||
<button
|
||
class="turn-into-item"
|
||
data-testid=testid
|
||
on:click=move |_| {
|
||
apply_feedback_result(set_command_feedback, run_slash_action(editor, kind));
|
||
set_turn_into_open.set(false);
|
||
}
|
||
>
|
||
{label}
|
||
<span>{description}</span>
|
||
</button>
|
||
}
|
||
}).collect_view()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="floating-toolbar-group">
|
||
<button
|
||
class="toolbar-btn"
|
||
data-active=move || selection_state.get().bold.to_string()
|
||
data-testid="toolbar-bold"
|
||
on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_bold(), "已更新行内样式")
|
||
>
|
||
"B"
|
||
</button>
|
||
<button
|
||
class="toolbar-btn"
|
||
data-active=move || selection_state.get().italic.to_string()
|
||
data-testid="toolbar-italic"
|
||
on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_italic(), "已更新行内样式")
|
||
>
|
||
"I"
|
||
</button>
|
||
<button
|
||
class="toolbar-btn"
|
||
data-active=move || selection_state.get().underline.to_string()
|
||
data-testid="toolbar-underline"
|
||
on:click=move |_| apply_ok_feedback(
|
||
set_command_feedback,
|
||
editor.toggle_mark(TiptapMarkName::Underline, None, None),
|
||
"已更新行内样式",
|
||
)
|
||
>
|
||
"U"
|
||
</button>
|
||
<button
|
||
class="toolbar-btn"
|
||
data-active=move || selection_state.get().strike.to_string()
|
||
data-testid="toolbar-strike"
|
||
on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_strike(), "已更新行内样式")
|
||
>
|
||
"S"
|
||
</button>
|
||
<button
|
||
class="toolbar-btn"
|
||
data-active="false"
|
||
data-testid="toolbar-code"
|
||
on:click=move |_| apply_ok_feedback(set_command_feedback, editor.toggle_code(), "已更新行内样式")
|
||
>
|
||
"</>"
|
||
</button>
|
||
<button
|
||
class="toolbar-btn"
|
||
data-active=move || selection_state.get().link.to_string()
|
||
data-testid="toolbar-link"
|
||
on:click=on_link_click
|
||
>
|
||
"链接"
|
||
</button>
|
||
<div class="toolbar-popover-anchor">
|
||
<button
|
||
class="toolbar-btn"
|
||
data-wide="true"
|
||
data-active=move || (selection_state.get().text_style
|
||
|| selection_state.get().highlight
|
||
|| color_menu_open.get()).to_string()
|
||
data-testid="toolbar-color"
|
||
on:click=move |_| {
|
||
let next_open = !color_menu_open.get_untracked();
|
||
if next_open {
|
||
set_locked_toolbar_anchor
|
||
.set(floating_toolbar_anchor.get_untracked());
|
||
}
|
||
set_color_menu_open.set(next_open);
|
||
set_more_menu_open.set(false);
|
||
set_turn_into_open.set(false);
|
||
}
|
||
>
|
||
"颜色"
|
||
</button>
|
||
<div
|
||
class="toolbar-popover-panel"
|
||
data-testid="toolbar-color-panel"
|
||
style:display=move || if color_menu_open.get() { "grid" } else { "none" }
|
||
on:mousedown=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
}
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
}
|
||
on:wheel=move |event: WheelEvent| {
|
||
trap_scroll_inside_menu(&event);
|
||
}
|
||
>
|
||
<div class="toolbar-popover-section">
|
||
<div class="toolbar-popover-title">"文字颜色"</div>
|
||
<div class="toolbar-color-grid">
|
||
{TOOLBAR_TEXT_COLORS.iter().map(|option| {
|
||
let value = option.value;
|
||
let label = option.label;
|
||
let testid = format!("toolbar-text-color-{}", option.id);
|
||
view! {
|
||
<button
|
||
class="toolbar-menu-item toolbar-color-item"
|
||
data-testid=testid
|
||
on:click=move |_| {
|
||
apply_feedback_result(
|
||
set_command_feedback,
|
||
apply_text_color(editor, value),
|
||
);
|
||
set_color_menu_open.set(false);
|
||
}
|
||
>
|
||
<span
|
||
class="toolbar-color-chip"
|
||
style:background=value
|
||
></span>
|
||
<strong>{label}</strong>
|
||
</button>
|
||
}
|
||
}).collect_view()}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toolbar-popover-section">
|
||
<div class="toolbar-popover-title">"背景颜色"</div>
|
||
<div class="toolbar-color-grid">
|
||
{TOOLBAR_HIGHLIGHT_COLORS.iter().map(|option| {
|
||
let value = option.value;
|
||
let label = option.label;
|
||
let testid = format!("toolbar-highlight-color-{}", option.id);
|
||
view! {
|
||
<button
|
||
class="toolbar-menu-item toolbar-color-item"
|
||
data-testid=testid
|
||
on:click=move |_| {
|
||
apply_feedback_result(
|
||
set_command_feedback,
|
||
apply_highlight_color(editor, Some(value)),
|
||
);
|
||
set_color_menu_open.set(false);
|
||
}
|
||
>
|
||
<span
|
||
class="toolbar-color-chip"
|
||
style:background=value
|
||
></span>
|
||
<strong>{label}</strong>
|
||
</button>
|
||
}
|
||
}).collect_view()}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toolbar-popover-section">
|
||
<button
|
||
class="toolbar-menu-item"
|
||
data-testid="toolbar-clear-color"
|
||
on:click=move |_| {
|
||
apply_feedback_result(
|
||
set_command_feedback,
|
||
clear_text_color(editor),
|
||
);
|
||
let _ = apply_highlight_color(editor, None);
|
||
set_color_menu_open.set(false);
|
||
}
|
||
>
|
||
<strong>"清除颜色"</strong>
|
||
<span>"移除文字色与背景色"</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="floating-toolbar-group">
|
||
<div class="toolbar-popover-anchor">
|
||
<button
|
||
class="toolbar-btn"
|
||
data-wide="true"
|
||
data-active=move || (selection_state.get().align_center
|
||
|| selection_state.get().align_right
|
||
|| selection_state.get().align_justify
|
||
|| more_menu_open.get()).to_string()
|
||
data-testid="toolbar-more"
|
||
on:click=move |_| {
|
||
let next_open = !more_menu_open.get_untracked();
|
||
if next_open {
|
||
set_locked_toolbar_anchor
|
||
.set(floating_toolbar_anchor.get_untracked());
|
||
}
|
||
set_more_menu_open.set(next_open);
|
||
set_color_menu_open.set(false);
|
||
set_turn_into_open.set(false);
|
||
}
|
||
>
|
||
"更多"
|
||
</button>
|
||
<div
|
||
class="toolbar-popover-panel"
|
||
data-testid="toolbar-more-panel"
|
||
style:display=move || if more_menu_open.get() { "grid" } else { "none" }
|
||
on:mousedown=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
}
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
}
|
||
on:wheel=move |event: WheelEvent| {
|
||
trap_scroll_inside_menu(&event);
|
||
}
|
||
>
|
||
<div class="toolbar-popover-section">
|
||
<div class="toolbar-popover-title">"对齐方式"</div>
|
||
<div class="toolbar-more-list">
|
||
{TOOLBAR_ALIGN_OPTIONS.iter().map(|option| {
|
||
let label = option.label;
|
||
let alignment = option.alignment;
|
||
let testid = format!("toolbar-align-{}", option.id);
|
||
let active = match option.alignment {
|
||
TiptapTextAlign::Left => selection_state.get().align_left,
|
||
TiptapTextAlign::Center => selection_state.get().align_center,
|
||
TiptapTextAlign::Right => selection_state.get().align_right,
|
||
TiptapTextAlign::Justify => selection_state.get().align_justify,
|
||
};
|
||
view! {
|
||
<button
|
||
class="toolbar-menu-item toolbar-more-item"
|
||
data-active=active.to_string()
|
||
data-testid=testid
|
||
on:click=move |_| {
|
||
apply_feedback_result(
|
||
set_command_feedback,
|
||
apply_text_align(editor, alignment),
|
||
);
|
||
set_more_menu_open.set(false);
|
||
}
|
||
>
|
||
<strong>{label}</strong>
|
||
<span>"按官方浮动工具条对当前块对齐"</span>
|
||
</button>
|
||
}
|
||
}).collect_view()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}.into_any()
|
||
} else {
|
||
().into_any()
|
||
}
|
||
}}
|
||
|
||
{move || {
|
||
if let Some(indicator) = drop_indicator.get() {
|
||
let drop_indicator_left = editor_stage_element()
|
||
.map(|stage| {
|
||
let stage_rect = stage.get_bounding_client_rect();
|
||
format!("{}px", content_column_left(stage_rect.width()))
|
||
})
|
||
.unwrap_or_else(|| "56px".to_string());
|
||
let drop_indicator_right = editor_stage_element()
|
||
.map(|stage| {
|
||
let stage_rect = stage.get_bounding_client_rect();
|
||
let content_left = content_column_left(stage_rect.width());
|
||
format!("{}px", content_left)
|
||
})
|
||
.unwrap_or_else(|| "56px".to_string());
|
||
view! {
|
||
<div
|
||
class="block-drop-indicator"
|
||
data-testid="block-drop-indicator"
|
||
style:top=format!("{}px", indicator.top)
|
||
style:left=drop_indicator_left
|
||
style:right=drop_indicator_right
|
||
></div>
|
||
}.into_any()
|
||
} else {
|
||
().into_any()
|
||
}
|
||
}}
|
||
|
||
{move || {
|
||
let active_block = if block_menu_open.get() {
|
||
block_menu_anchor.get()
|
||
} else if dragging_block_index.get().is_some() {
|
||
dragging_block_anchor.get()
|
||
} else {
|
||
hovered_block.get().or_else(|| {
|
||
if block_keyboard_mode.get()
|
||
&& editor_focused.get()
|
||
&& !text_selection_active.get()
|
||
&& !slash_open.get()
|
||
{
|
||
hovered_block_from_selection()
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
};
|
||
|
||
if let Some(block) = active_block {
|
||
let dragging_index = dragging_block_index.get();
|
||
let is_dragging = dragging_index
|
||
.map(|active| active == block.index)
|
||
.unwrap_or(false);
|
||
|
||
if !editor_focused.get()
|
||
|| text_selection_active.get()
|
||
|| slash_open.get()
|
||
|| (dragging_index.is_some() && !is_dragging)
|
||
{
|
||
return ().into_any();
|
||
}
|
||
|
||
let anchor_top = format!("{}px", hover_anchor_top(&block));
|
||
let anchor_transform = hover_anchor_transform(&block);
|
||
let handle_left = editor_stage_element()
|
||
.map(|stage| {
|
||
let stage_rect = stage.get_bounding_client_rect();
|
||
format!("{}px", handle_lane_left(stage_rect.width()))
|
||
})
|
||
.unwrap_or_else(|| "12px".to_string());
|
||
let block_index = block.index;
|
||
let block_label = block.label.clone();
|
||
let click_block_label = block_label.clone();
|
||
let duplicate_block_label = block_label.clone();
|
||
let delete_block_label = block_label.clone();
|
||
let insert_before_block_label = block_label.clone();
|
||
let insert_after_block_label = block_label.clone();
|
||
let click_anchor_block = block.clone();
|
||
let drag_anchor_block = block.clone();
|
||
|
||
view! {
|
||
<div
|
||
class="block-handle-shell"
|
||
data-testid="mnote-leptos-tiptap-handle"
|
||
data-dragging=move || if is_dragging { "true" } else { "false" }
|
||
data-keyboard-selected=move || block_keyboard_mode.get().to_string()
|
||
data-menu-open=move || if block_menu_open.get()
|
||
&& block_menu_anchor
|
||
.get()
|
||
.map(|current| current.index == block_index)
|
||
.unwrap_or(false) { "true" } else { "false" }
|
||
style:left=handle_left
|
||
style:top=anchor_top
|
||
style:transform=anchor_transform
|
||
>
|
||
<button
|
||
class="block-handle-insert block-handle-insert-before"
|
||
data-testid="block-insert-before-trigger"
|
||
aria-label="在上方插入块"
|
||
title="在上方插入块 · Esc, a"
|
||
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);
|
||
set_block_keyboard_mode.set(false);
|
||
match insert_editor_paragraph_relative_to_block(editor, block_index, true) {
|
||
Ok(new_index) => {
|
||
sync_persisted_editor_command(
|
||
editor,
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
title,
|
||
set_command_feedback,
|
||
format!("已在 {} 上方插入新块", insert_before_block_label),
|
||
);
|
||
set_slash_open.set(false);
|
||
set_slash_index.set(0);
|
||
set_hovered_block.set(block_state_from_index(new_index));
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(err);
|
||
}
|
||
}
|
||
}
|
||
>
|
||
<span class="block-insert-line"></span>
|
||
<span class="block-insert-plus">"+"</span>
|
||
<span class="block-insert-tooltip">"在上方插入块" <kbd>"Esc, a"</kbd></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));
|
||
}
|
||
>
|
||
<span class="block-handle-icon block-handle-icon-dots" aria-hidden="true">
|
||
<span data-testid="block-handle-dot"></span>
|
||
<span data-testid="block-handle-dot"></span>
|
||
<span data-testid="block-handle-dot"></span>
|
||
<span data-testid="block-handle-dot"></span>
|
||
</span>
|
||
<span class="block-handle-icon block-handle-icon-lines" aria-hidden="true">
|
||
<span data-testid="block-handle-line"></span>
|
||
<span data-testid="block-handle-line"></span>
|
||
<span data-testid="block-handle-line"></span>
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
class="block-handle-insert block-handle-insert-after"
|
||
data-testid="block-insert-after-trigger"
|
||
aria-label="在下方插入块"
|
||
title="在下方插入块 · Esc, b"
|
||
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);
|
||
set_block_keyboard_mode.set(false);
|
||
match insert_editor_paragraph_relative_to_block(editor, block_index, false) {
|
||
Ok(new_index) => {
|
||
sync_persisted_editor_command(
|
||
editor,
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
title,
|
||
set_command_feedback,
|
||
format!("已在 {} 下方插入新块", insert_after_block_label),
|
||
);
|
||
set_slash_open.set(false);
|
||
set_slash_index.set(0);
|
||
set_hovered_block.set(block_state_from_index(new_index));
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(err);
|
||
}
|
||
}
|
||
}
|
||
>
|
||
<span class="block-insert-line"></span>
|
||
<span class="block-insert-plus">"+"</span>
|
||
<span class="block-insert-tooltip">"在下方插入块" <kbd>"Esc, b"</kbd></span>
|
||
</button>
|
||
|
||
|
||
{move || {
|
||
if block_menu_open.get()
|
||
&& block_menu_anchor
|
||
.get()
|
||
.map(|current| current.index == block_index)
|
||
.unwrap_or(false)
|
||
{
|
||
let current_duplicate_label = duplicate_block_label.clone();
|
||
let current_delete_label = delete_block_label.clone();
|
||
let menu_layout = block_menu_layout(&block).unwrap_or(BlockMenuLayout {
|
||
max_height: 360.0,
|
||
open_upward: false,
|
||
top: 80.0,
|
||
left: 80.0,
|
||
});
|
||
let menu_top = format!("{}px", menu_layout.top);
|
||
let menu_bottom = "auto";
|
||
let current_block_is_page = top_level_block_is_page(editor, block_index);
|
||
let page_current_marker = if current_block_is_page { "✓" } else { "Ctrl+Shift+9" };
|
||
view! {
|
||
<div
|
||
class="block-drag-menu"
|
||
data-testid="block-drag-menu"
|
||
style:top=menu_top
|
||
style:bottom=menu_bottom
|
||
style:left=format!("{}px", menu_layout.left)
|
||
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);
|
||
}
|
||
>
|
||
<input
|
||
class="block-drag-command-input"
|
||
data-testid="block-drag-command-input"
|
||
readonly=true
|
||
placeholder="请输入指令"
|
||
/>
|
||
<div class="block-drag-menu-actions">
|
||
{move || {
|
||
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-ai"
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
set_command_feedback.set("AI 助理入口已打开".to_string());
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"✦"</span>
|
||
<span>"AI 助理"</span>
|
||
<span class="block-drag-menu-shortcut">"Ctrl+J"</span>
|
||
</button>
|
||
<button
|
||
class="block-drag-menu-item"
|
||
data-testid="block-drag-menu-item-turn-into"
|
||
on:mouseenter=move |_| {
|
||
set_block_turn_into_open.set(true);
|
||
}
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
set_block_turn_into_open.set(true);
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"↻"</span>
|
||
<span>"转换为"</span>
|
||
<span class="block-drag-menu-arrow">"›"</span>
|
||
</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,
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
next_html,
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
title,
|
||
set_command_feedback,
|
||
format!("已复制 {}", duplicate_feedback_label.clone()),
|
||
);
|
||
set_block_menu_open.set(false);
|
||
set_block_menu_anchor.set(None);
|
||
}
|
||
Err(err) => set_command_feedback.set(err),
|
||
},
|
||
Err(err) => set_command_feedback.set(format!("读取当前 HTML 失败:{err}")),
|
||
}
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"▣"</span>
|
||
<span>"拷贝副本"</span>
|
||
<span class="block-drag-menu-shortcut">"Ctrl+D"</span>
|
||
</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,
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
next_html,
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
title,
|
||
set_command_feedback,
|
||
format!("已删除 {}", delete_feedback_label.clone()),
|
||
);
|
||
set_block_menu_open.set(false);
|
||
set_block_menu_anchor.set(None);
|
||
set_hovered_block.set(None);
|
||
}
|
||
Err(err) => set_command_feedback.set(err),
|
||
},
|
||
Err(err) => set_command_feedback.set(format!("读取当前 HTML 失败:{err}")),
|
||
}
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"⌫"</span>
|
||
<span>"删除"</span>
|
||
<span class="block-drag-menu-shortcut">"Del"</span>
|
||
</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-copy-link"
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
set_command_feedback.set(format!("已复制第 {} 个块的链接", block_index + 1));
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"🔗"</span>
|
||
<span>"复制链接"</span>
|
||
<span class="block-drag-menu-arrow">"›"</span>
|
||
</button>
|
||
<button
|
||
class="block-drag-menu-item"
|
||
data-testid="block-drag-menu-item-move"
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
set_command_feedback.set("移动/嵌入流程后续接 Page Aggregate 命令".to_string());
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"↗"</span>
|
||
<span>"移动/嵌入到..."</span>
|
||
<span class="block-drag-menu-shortcut">"Alt+Shift+M/G"</span>
|
||
</button>
|
||
<button
|
||
class="block-drag-menu-item"
|
||
data-testid="block-drag-menu-item-history"
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
set_command_feedback.set("块历史入口已记录,恢复流程后续接版本链路".to_string());
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"◴"</span>
|
||
<span>"块历史..."</span>
|
||
</button>
|
||
<button
|
||
class="block-drag-menu-item"
|
||
data-testid="block-drag-menu-item-comment"
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
set_command_feedback.set("评论入口已打开".to_string());
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"◉"</span>
|
||
<span>"评论"</span>
|
||
<span class="block-drag-menu-shortcut">"Ctrl+Alt+M"</span>
|
||
</button>
|
||
<button
|
||
class="block-drag-menu-item"
|
||
data-testid="block-drag-menu-item-color"
|
||
on:mouseenter=move |_| {
|
||
set_command_feedback.set("颜色子菜单后续按 Wolai 继续补齐".to_string());
|
||
}
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
set_command_feedback.set("颜色子菜单后续按 Wolai 继续补齐".to_string());
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"▧"</span>
|
||
<span>"颜色"</span>
|
||
<span class="block-drag-menu-arrow">"›"</span>
|
||
</button>
|
||
<button
|
||
class="block-drag-menu-item"
|
||
data-testid="block-drag-menu-item-align-center"
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
let result = (|| -> Result<(), String> {
|
||
let document = editor
|
||
.get_json()
|
||
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
|
||
let range = top_level_block_range(&document, block_index)
|
||
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;
|
||
editor
|
||
.set_text_selection(range)
|
||
.map_err(|err| format!("选中当前块失败:{err}"))?;
|
||
apply_text_align(editor, TiptapTextAlign::Center)?;
|
||
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);
|
||
persist_document_state(
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
&title.get_untracked(),
|
||
&snapshot,
|
||
Some(html),
|
||
)
|
||
.map_err(|err| format!("文字居中已执行,但本地保存失败:{err}"))?;
|
||
Ok(())
|
||
})();
|
||
match result {
|
||
Ok(()) => set_command_feedback.set("已切到居中,并已写入本地草稿".to_string()),
|
||
Err(err) => set_command_feedback.set(err),
|
||
}
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"≡"</span>
|
||
<span>"文字居中"</span>
|
||
<span class="block-drag-menu-switch"></span>
|
||
</button>
|
||
<button class="block-drag-menu-item" data-testid="block-drag-menu-item-translate" disabled=true>
|
||
<span class="block-drag-menu-icon">"文"</span>
|
||
<span>"文字翻译"</span>
|
||
<span class="block-drag-menu-arrow">"›"</span>
|
||
</button>
|
||
<button class="block-drag-menu-item" data-testid="block-drag-menu-item-poster" disabled=true>
|
||
<span class="block-drag-menu-icon">"▤"</span>
|
||
<span>"生成海报"</span>
|
||
</button>
|
||
</div>
|
||
<div class="block-drag-menu-footer" data-testid="block-drag-menu-footer">
|
||
<span>"anonymous"</span>
|
||
<span>"最后编辑于 刚刚"</span>
|
||
</div>
|
||
{move || {
|
||
if block_turn_into_open.get() {
|
||
view! {
|
||
<div class="block-drag-submenu" data-testid="block-transform-submenu">
|
||
{SLASH_ACTIONS
|
||
.iter()
|
||
.filter(|action| !matches!(action.kind, SlashActionKind::Divider | SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi))
|
||
.map(|action| {
|
||
let kind = action.kind;
|
||
let label = action.label;
|
||
let icon = action.icon;
|
||
let shortcut = match action.kind {
|
||
SlashActionKind::Paragraph => "Ctrl+Alt+0",
|
||
SlashActionKind::Todo => "Ctrl+Shift+5",
|
||
SlashActionKind::Heading1 => "Ctrl+Shift+1",
|
||
SlashActionKind::Heading2 => "Ctrl+Shift+2",
|
||
SlashActionKind::Heading3 => "Ctrl+Shift+3",
|
||
SlashActionKind::BulletList => "Ctrl+Shift+6",
|
||
SlashActionKind::OrderedList => "Ctrl+Shift+7",
|
||
SlashActionKind::CodeBlock => "Ctrl+Alt+-",
|
||
_ => "",
|
||
};
|
||
let testid = format!("block-transform-item-{}", action.id);
|
||
let action_menu_label = "块".to_string();
|
||
view! {
|
||
<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);
|
||
set_block_turn_into_open.set(false);
|
||
let (html, snapshot, json_text) = read_editor_snapshot(editor);
|
||
set_dirty_count.update(|count| *count += 1);
|
||
set_html_output.set(html.clone());
|
||
set_document_json.set(snapshot.clone());
|
||
set_json_output.set(json_text);
|
||
match persist_document_state(
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
&title.get_untracked(),
|
||
&snapshot,
|
||
Some(html),
|
||
) {
|
||
Ok(()) => set_command_feedback.set(format!(
|
||
"{}:{} -> {},并已写入本地草稿",
|
||
message,
|
||
action_menu_label.clone(),
|
||
label,
|
||
)),
|
||
Err(err) => set_command_feedback.set(format!(
|
||
"{}:{} -> {},但本地保存失败:{}",
|
||
message,
|
||
action_menu_label.clone(),
|
||
label,
|
||
err,
|
||
)),
|
||
}
|
||
}
|
||
Err(err) => set_command_feedback.set(err),
|
||
}
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">{icon}</span>
|
||
<span>{label}</span>
|
||
<span class="block-drag-menu-shortcut">{shortcut}</span>
|
||
</button>
|
||
}
|
||
})
|
||
.collect_view()}
|
||
<button
|
||
class="block-drag-menu-item"
|
||
data-testid="block-transform-item-page"
|
||
data-current=current_block_is_page.to_string()
|
||
on:click=move |event: MouseEvent| {
|
||
event.stop_propagation();
|
||
run_block_turn_into_page_action(
|
||
editor,
|
||
block_index,
|
||
document_id,
|
||
workspace_id,
|
||
title,
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
set_command_feedback,
|
||
set_block_menu_open,
|
||
set_block_menu_anchor,
|
||
set_block_turn_into_open,
|
||
);
|
||
}
|
||
>
|
||
<span class="block-drag-menu-icon">"▣"</span>
|
||
<span>"页面"</span>
|
||
<span class="block-drag-menu-shortcut">{page_current_marker}</span>
|
||
</button>
|
||
<button class="block-drag-menu-item" data-testid="block-transform-item-folded-list" disabled=true><span class="block-drag-menu-icon">"≡"</span><span>"折叠列表"</span><span class="block-drag-menu-shortcut">"Ctrl+Shift+8"</span></button>
|
||
<button class="block-drag-menu-item" data-testid="block-transform-item-folded-title" disabled=true><span class="block-drag-menu-icon">"H1"</span><span>"折叠标题"</span><span class="block-drag-menu-arrow">"›"</span></button>
|
||
<button class="block-drag-menu-item" data-testid="block-transform-item-folded-todo" disabled=true><span class="block-drag-menu-icon">"☑"</span><span>"折叠待办"</span><span class="block-drag-menu-arrow">"›"</span></button>
|
||
<button class="block-drag-menu-item" data-testid="block-transform-item-emphasis" disabled=true><span class="block-drag-menu-icon">"Aa"</span><span>"着重文字"</span></button>
|
||
<button class="block-drag-menu-item" data-testid="block-transform-item-math" disabled=true><span class="block-drag-menu-icon">"f(x)"</span><span>"数学公式"</span><span class="block-drag-menu-shortcut">"Ctrl+Alt++"</span></button>
|
||
<button class="block-drag-menu-item" data-testid="block-transform-item-mindmap" disabled=true><span class="block-drag-menu-icon">"⌘"</span><span>"思维导图"</span></button>
|
||
</div>
|
||
}.into_any()
|
||
} else {
|
||
().into_any()
|
||
}
|
||
}}
|
||
</>
|
||
}
|
||
}}
|
||
</div>
|
||
</div>
|
||
}.into_any()
|
||
} else {
|
||
().into_any()
|
||
}
|
||
}}
|
||
</div>
|
||
}.into_any()
|
||
} else {
|
||
().into_any()
|
||
}
|
||
}}
|
||
|
||
{move || {
|
||
if slash_open.get() {
|
||
view! {
|
||
<div class="slash-menu" data-testid="mnote-leptos-tiptap-slash-menu">
|
||
<div class="slash-list">
|
||
{SLASH_ACTIONS.iter().enumerate().map(|(index, action)| {
|
||
let is_selected = move || slash_index.get() == index;
|
||
let kind = action.kind;
|
||
let label = action.label;
|
||
let description = action.description;
|
||
let shortcut = action.shortcut;
|
||
let icon = action.icon;
|
||
let category = action.category;
|
||
let show_category = index == 0
|
||
|| SLASH_ACTIONS[index - 1].category != category;
|
||
let testid = format!("slash-item-{}", action.id);
|
||
view! {
|
||
<>
|
||
{if show_category {
|
||
view! { <div class="slash-section-label">{category}</div> }.into_any()
|
||
} else {
|
||
().into_any()
|
||
}}
|
||
<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);
|
||
}
|
||
>
|
||
<span class="slash-item-row">
|
||
<span class="slash-item-icon">{icon}</span>
|
||
<span class="slash-item-copy">
|
||
<strong>{label}</strong>
|
||
<span>{description}</span>
|
||
</span>
|
||
<span class="slash-item-shortcut">{shortcut}</span>
|
||
</span>
|
||
</button>
|
||
</>
|
||
}
|
||
}).collect_view()}
|
||
</div>
|
||
</div>
|
||
}
|
||
.into_any()
|
||
} else {
|
||
().into_any()
|
||
}
|
||
}}
|
||
|
||
<TiptapEditor
|
||
id="mnote-leptos-tiptap-spike"
|
||
editor=editor
|
||
initial_content=initial_editor_content.clone()
|
||
placeholder="输入 “/” 打开命令菜单;试试 heading / list / todo / quote / code block / divider"
|
||
disabled=move || !editor_editable.get()
|
||
extensions=p0_extensions()
|
||
on_ready=move |_| {
|
||
if restored_from_storage {
|
||
let restored_content = restored_html
|
||
.clone()
|
||
.map(TiptapContent::html)
|
||
.unwrap_or_else(|| {
|
||
let restored_json = document_json.get_untracked();
|
||
let payload = restored_json
|
||
.get("content")
|
||
.and_then(Value::as_array)
|
||
.cloned()
|
||
.map(Value::Array)
|
||
.unwrap_or(restored_json);
|
||
TiptapContent::json(payload)
|
||
});
|
||
if let Err(err) = editor.set_content(restored_content) {
|
||
set_command_feedback.set(format!("恢复本地草稿失败:{err}"));
|
||
return;
|
||
}
|
||
}
|
||
let snapshot =
|
||
sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
|
||
dispatch_ready_event(&ReadyPayload {
|
||
runtime_name: RUNTIME_NAME,
|
||
selectors: BridgeSelectorsPayload {
|
||
root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
|
||
stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
|
||
editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
|
||
toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
|
||
slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
|
||
handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
|
||
},
|
||
supported_commands: vec![
|
||
"replaceContent",
|
||
"setEditable",
|
||
"undo",
|
||
"redo",
|
||
"focus",
|
||
"requestCurrentBlockId",
|
||
],
|
||
supports_embedded_mode: true,
|
||
});
|
||
dispatch_runtime_state(
|
||
document_id.get_untracked(),
|
||
workspace_id.get_untracked(),
|
||
title.get_untracked(),
|
||
dirty_count.get_untracked(),
|
||
hovered_block.get_untracked(),
|
||
editor_focused.get_untracked(),
|
||
slash_open.get_untracked(),
|
||
turn_into_open.get_untracked(),
|
||
color_menu_open.get_untracked(),
|
||
more_menu_open.get_untracked(),
|
||
read_only.get_untracked(),
|
||
);
|
||
match persist_document_state(
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
&title.get_untracked(),
|
||
&snapshot,
|
||
Some(html_output.get_untracked()),
|
||
) {
|
||
Ok(()) => {
|
||
if restored_from_storage {
|
||
set_command_feedback.set("编辑器已准备就绪,并已恢复本地草稿".to_string());
|
||
} else {
|
||
set_command_feedback.set("编辑器已准备就绪,当前内容已写入本地草稿".to_string());
|
||
}
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(format!("编辑器已准备就绪,但草稿初始化失败:{err}"));
|
||
}
|
||
}
|
||
}
|
||
on_change=move |_| {
|
||
set_dirty_count.update(|count| *count += 1);
|
||
let snapshot =
|
||
sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
|
||
dispatch_change_event(&ChangePayload {
|
||
document_id: document_id.get_untracked(),
|
||
workspace_id: workspace_id.get_untracked(),
|
||
title: title.get_untracked(),
|
||
content: snapshot.clone(),
|
||
meta: ChangeMetaPayload {
|
||
dirty_count: dirty_count.get_untracked(),
|
||
editor_focused: editor_focused.get_untracked(),
|
||
slash_open: slash_open.get_untracked(),
|
||
toolbar_open: toolbar_overlay_locked(
|
||
turn_into_open.get_untracked(),
|
||
color_menu_open.get_untracked(),
|
||
more_menu_open.get_untracked(),
|
||
),
|
||
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
|
||
revision: revision.get_untracked(),
|
||
conflict_detection_key: conflict_detection_key.get_untracked(),
|
||
read_only: read_only.get_untracked(),
|
||
},
|
||
});
|
||
dispatch_runtime_state(
|
||
document_id.get_untracked(),
|
||
workspace_id.get_untracked(),
|
||
title.get_untracked(),
|
||
dirty_count.get_untracked(),
|
||
hovered_block.get_untracked(),
|
||
editor_focused.get_untracked(),
|
||
slash_open.get_untracked(),
|
||
turn_into_open.get_untracked(),
|
||
color_menu_open.get_untracked(),
|
||
more_menu_open.get_untracked(),
|
||
read_only.get_untracked(),
|
||
);
|
||
match persist_document_state(
|
||
&runtime_persisted_identity(document_id, workspace_id),
|
||
&title.get_untracked(),
|
||
&snapshot,
|
||
Some(html_output.get_untracked()),
|
||
) {
|
||
Ok(()) => {
|
||
set_command_feedback.set("内容已变更,已同步观察值并写入本地草稿".to_string());
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(format!("内容已变更,但本地保存失败:{err}"));
|
||
}
|
||
}
|
||
}
|
||
on_selection_change=move |selection: TiptapSelectionState| {
|
||
let selection_clone = selection.clone();
|
||
set_selection_state.set(selection_clone.clone());
|
||
let toolbar_locked = toolbar_overlay_locked(
|
||
turn_into_open.get_untracked(),
|
||
color_menu_open.get_untracked(),
|
||
more_menu_open.get_untracked(),
|
||
);
|
||
let has_text_selection = if has_active_text_selection() {
|
||
sync_text_selection_overlay(
|
||
set_text_selection_active,
|
||
set_floating_toolbar_anchor,
|
||
)
|
||
} else if toolbar_locked {
|
||
text_selection_active.get_untracked()
|
||
} else {
|
||
set_text_selection_active.set(false);
|
||
set_floating_toolbar_anchor.set(None);
|
||
false
|
||
};
|
||
if has_text_selection {
|
||
if !toolbar_locked {
|
||
set_turn_into_open.set(false);
|
||
}
|
||
set_block_menu_open.set(false);
|
||
set_block_menu_anchor.set(None);
|
||
set_hovered_block.set(None);
|
||
}
|
||
send_selection_state(
|
||
&selection_clone,
|
||
editor_focused.get_untracked(),
|
||
hovered_block.get_untracked().map(|block| block.index),
|
||
);
|
||
}
|
||
attr:class="editor-surface"
|
||
attr:data-testid="mnote-leptos-tiptap-editor-root"
|
||
attr:data-small-text=move || small_text.get().to_string()
|
||
attr:data-layout-density=move || layout_density.get()
|
||
attr:data-show-heading-numbers=move || show_heading_numbers.get().to_string()
|
||
attr:data-embed-default-block-id=move || embed_default_block_id.get().unwrap_or_default()
|
||
/>
|
||
</div>
|
||
|
||
{move || {
|
||
if is_embedded {
|
||
().into_any()
|
||
} else {
|
||
view! {
|
||
<div class="footer-strip">
|
||
<span>
|
||
"当前 P0 边界:"
|
||
<code>"paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider"</code>
|
||
</span>
|
||
<span>
|
||
"运行时扩展不再走 "
|
||
<code>"TiptapExtension::all_enabled()"</code>
|
||
",这里已冻结为最小集合;当前 reload 走 "
|
||
<code>"localStorage JSON"</code>
|
||
",块菜单支持复制、删除、turn into 与顶层拖拽。"
|
||
</span>
|
||
</div>
|
||
}.into_any()
|
||
}
|
||
}}
|
||
</section>
|
||
|
||
{move || {
|
||
if is_embedded {
|
||
().into_any()
|
||
} else {
|
||
view! {
|
||
<details class="debug-drawer">
|
||
<summary>
|
||
<span>"调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面"</span>
|
||
<span>"展开 / 收起"</span>
|
||
</summary>
|
||
<div class="debug-grid">
|
||
<section class="debug-card">
|
||
<h3>"Selection"</h3>
|
||
<pre id="editor-selection-summary">{selection_text}</pre>
|
||
</section>
|
||
<section class="debug-card">
|
||
<h3>"HTML"</h3>
|
||
<pre id="editor-html-output">{move || html_output.get()}</pre>
|
||
</section>
|
||
<section class="debug-card">
|
||
<h3>"JSON"</h3>
|
||
<pre id="editor-json-output">{move || json_output.get()}</pre>
|
||
</section>
|
||
</div>
|
||
</details>
|
||
}.into_any()
|
||
}
|
||
}}
|
||
</main>
|
||
}
|
||
}
|
||
|
||
fn initial_content() -> TiptapContent {
|
||
TiptapContent::json(serde_json::json!({
|
||
"type": "doc",
|
||
"content": []
|
||
}))
|
||
}
|
||
|
||
pub fn standalone_main() {
|
||
console_error_panic_hook::set_once();
|
||
mount_to_body(|| {
|
||
view! { <App mount_options=MountOptions::default()/> }
|
||
});
|
||
}
|