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

10077 lines
417 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
TiptapImageResource, TiptapInsertContentOptions, TiptapLinkResource, TiptapMarkName,
TiptapNodeName, TiptapRange, TiptapSchemaTarget, TiptapSelectionState, TiptapTextAlign,
TiptapTocNodeAttrs,
};
use send_wrapper::SendWrapper;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display};
use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
use wasm_bindgen_futures::{spawn_local, JsFuture};
use web_sys::{
window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement,
HtmlInputElement, MouseEvent, Node, RequestInit, RequestMode, Response, WheelEvent,
};
use editor_runtime::attachment_upload::dispatch_editor_upload_request;
use editor_runtime::block_dnd::{
direct_block_from_element, direct_block_index_from_element,
drop_indicator_from_block_rects, element_within_handle_shell, event_target_from_point,
pointer_in_handle_corridor_geometry, top_level_block_index, HandleCorridorGeometry,
};
use editor_runtime::block_menu_overlay;
use editor_runtime::block_menu_document::{
collect_plain_text, mindmap_paragraph_node, paragraph_node,
};
use editor_runtime::block_menu_legacy_html::{
duplicate_top_level_block_html, reorder_top_level_block_html,
};
use editor_runtime::block_hover_state::{
BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState,
};
use editor_runtime::command_sync::{
read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command,
};
use editor_runtime::dom_events::{event_target_matches_selector, target_element};
use editor_runtime::dom_selection::{
runtime_block_id_from_index, selection_payload, selection_summary, SelectionPayload,
};
use editor_runtime::editor_focus::{active_editor_stage, body_has_focus, schedule_editor_focus};
use editor_runtime::overlays::{
close_editor_floating_overlays_if_escape, open_block_menu_overlay, open_image_toolbar_overlay,
open_slash_menu_overlay, current_table_overlay_anchor, sync_overlays_on_selection_change,
table_col_aux_style, table_overlay_anchor_from_table, table_row_aux_style,
toolbar_overlay_locked, try_sync_editor_overlay_state, FloatingToolbarAnchor,
ImageToolbarAnchor, TableOverlayAnchor, TableSelectionKind, TableSelectionOverlayState,
};
use editor_runtime::persistence::{
load_persisted_document, normalize_identity_value, persist_document_state,
persisted_document_identity, PersistedDocumentIdentity,
};
#[cfg(test)]
use editor_runtime::persistence::persisted_document_storage_key;
const EDITOR_STAGE_SELECTOR: &str = "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]";
const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror";
const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell";
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 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 BLOCK_DELTA_EVENT: &str = "mnote:editor:block-delta";
const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height";
const MINDMAP_SHELL_ACTION_EVENT: &str = "mnote:mindmap-shell:action";
const MINDMAP_SHELL_PANEL_EVENT: &str = "mnote:mindmap-shell:panel";
const MINDMAP_SHELL_MINIMAP_EVENT: &str = "mnote:mindmap-shell:minimap";
const MINDMAP_SHELL_ZOOM_EVENT: &str = "mnote:mindmap-shell:zoom";
const MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT: &str = "mnote:mindmap-shell:toolbar-overflow";
mod editor_runtime;
#[wasm_bindgen(inline_js = r#"
export function write_mnote_text_to_clipboard(text) {
try {
if (navigator?.clipboard?.writeText) {
return navigator.clipboard.writeText(String(text)).then(() => true, () => false);
}
} catch (_error) {}
return Promise.resolve(false);
}
export function find_mnote_block_anchor(blockId) {
const wanted = String(blockId || '');
if (!wanted) return null;
const root = document.querySelector('.editor-surface .ProseMirror');
if (!root) return null;
return Array.from(root.querySelectorAll('[data-block-id]')).find((node) => node.getAttribute('data-block-id') === wanted) || null;
}
export function select_mnote_image_node(image) {
try {
const root = image?.closest?.('.ProseMirror');
const editor = root?.editor;
const view = editor?.view;
if (!editor || !view || typeof view.posAtDOM !== 'function') return false;
const pos = view.posAtDOM(image, 0);
if (!Number.isFinite(pos)) return false;
return editor.chain().focus().setNodeSelection(pos).run() === true;
} catch (error) {
console.warn('mnote image NodeSelection failed', error);
return false;
}
}
function mnoteImageExtension(url, contentType) {
const urlMatch = String(url || '').match(/\.([a-zA-Z0-9]+)(?:[?#].*)?$/);
if (urlMatch?.[1]) return `.${urlMatch[1].toLowerCase()}`;
const mimeMap = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/bmp': '.bmp',
};
return mimeMap[String(contentType || '').toLowerCase()] || '.jpg';
}
function mnoteImageFilename(image, url) {
const raw = image.getAttribute('title') || image.getAttribute('alt') || `image-${Date.now()}`;
const base = raw.replace(/[\/:*?"<>|]+/g, '-').trim() || `image-${Date.now()}`;
return /\.[a-zA-Z0-9]+$/.test(base) ? base : `${base}${mnoteImageExtension(url)}`;
}
export function download_mnote_selected_image() {
try {
const image = document.querySelector('.editor-surface .ProseMirror img.ProseMirror-selectednode[src]');
if (!(image instanceof HTMLImageElement)) return false;
const src = image.getAttribute('src');
if (!src) return false;
const url = new URL(src, window.location.href);
if (!(url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'data:')) return false;
const link = document.createElement('a');
link.href = url.href;
link.download = mnoteImageFilename(image, url.href);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
return true;
} catch (error) {
console.warn('mnote image download failed', error);
return false;
}
}
export function bump_mnote_e27_ai_bridge_request_count() {
try {
const key = '__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__';
window[key] = Number(window[key] || 0) + 1;
return window[key];
} catch (_error) {
return 0;
}
}
export async function post_mnote_document_save(body) {
const response = await fetch('/api/documents/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: String(body || '{}'),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`document save failed: HTTP ${response.status}; ${text.slice(0, 1200)}`);
}
return text || '{}';
}
"#)]
extern "C" {
#[wasm_bindgen(catch)]
async fn write_mnote_text_to_clipboard(text: String) -> Result<JsValue, JsValue>;
fn find_mnote_block_anchor(block_id: &str) -> Option<Element>;
fn select_mnote_image_node(image: &Element) -> bool;
fn download_mnote_selected_image() -> bool;
fn bump_mnote_e27_ai_bridge_request_count() -> u32;
#[wasm_bindgen(catch)]
async fn post_mnote_document_save(body: String) -> Result<JsValue, JsValue>;
}
#[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);
}
.mnote-mindmap-placeholder {
width: min(1280px, calc(100vw - 340px));
margin: 10px 0;
margin-left: 50%;
transform: translateX(-50%);
}
.mnote-mindmap-editor-root {
position: relative;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.32);
border-radius: 8px;
background: #ffffff;
color: #0f172a;
}
.mnote-leptos-mindmap-shell {
position: relative;
height: min(72vh, 720px);
min-height: 560px;
display: flex;
flex-direction: column;
}
.mnote-mindmap-command-toolbar,
.mnote-mindmap-bottom-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px;
border-bottom: 1px solid rgba(148, 163, 184, 0.26);
background: #ffffff;
}
.mnote-mindmap-command-toolbar {
flex-wrap: nowrap;
}
.mnote-mindmap-schema-toolbar {
position: absolute;
left: 50%;
top: 22px;
z-index: 4;
width: max-content;
max-width: min(980px, calc(100% - 184px));
box-sizing: border-box;
transform: translateX(-50%);
gap: 8px;
padding: 7px 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 6px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(10px);
overflow-x: visible;
overflow-y: visible;
scrollbar-width: none;
}
.mnote-mindmap-toolbar-primary,
.mnote-mindmap-toolbar-secondary {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
flex-wrap: nowrap;
min-width: 0;
}
.mnote-mindmap-toolbar-cluster {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
min-width: 0;
white-space: nowrap;
}
.mnote-mindmap-toolbar-cluster-file {
padding-left: 8px;
border-left: 1px solid rgba(148, 163, 184, 0.24);
}
.mnote-mindmap-toolbar-group {
display: inline-flex;
align-items: center;
gap: 4px;
padding-right: 10px;
border-right: 1px solid rgba(148, 163, 184, 0.22);
}
.mnote-mindmap-toolbar-group:last-child {
border-right: 0;
}
.mnote-mindmap-toolbar-group-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
color: #64748b;
font-size: 11px;
}
.mnote-mindmap-toolbar-primary input,
.mnote-mindmap-bottom-bar input {
height: 28px;
border: 1px solid rgba(148, 163, 184, 0.42);
border-radius: 6px;
padding: 0 8px;
background: #fff;
color: #0f172a;
font-size: 12px;
}
.mnote-mindmap-tool,
.mnote-mindmap-bottom-bar button,
.mnote-mindmap-side-tab {
height: 28px;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 6px;
padding: 0 9px;
background: #fff;
color: #334155;
font-size: 12px;
cursor: pointer;
}
.mnote-mindmap-schema-toolbar .mnote-mindmap-tool {
flex-direction: column;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
width: 46px;
height: 40px;
min-width: 46px;
padding: 3px 4px;
color: #475569;
}
.mnote-mindmap-tool-icon {
min-width: 16px;
font-size: 15px;
line-height: 1;
}
.mnote-mindmap-tool-label {
max-width: 42px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
line-height: 14px;
}
.mnote-mindmap-tool:disabled,
.mnote-mindmap-bottom-bar button:disabled,
.mnote-mindmap-side-tab:disabled {
cursor: default;
opacity: 0.48;
}
.mnote-mindmap-toolbar-more {
position: relative;
display: inline-flex;
flex: 0 0 auto;
}
.mnote-mindmap-toolbar-more-button[data-open="true"] {
border-color: rgba(37, 99, 235, 0.42);
background: #eff6ff;
color: #1d4ed8;
}
.mnote-mindmap-toolbar-more-menu {
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 8;
display: grid;
grid-template-columns: 1fr;
gap: 4px;
min-width: 128px;
padding: 8px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 6px;
background: #ffffff;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16);
}
.mnote-mindmap-toolbar-more-menu .mnote-mindmap-tool {
flex-direction: row;
justify-content: flex-start;
width: 100%;
min-width: 112px;
height: 30px;
gap: 8px;
padding: 0 8px;
}
.mnote-mindmap-toolbar-more-menu .mnote-mindmap-tool-label {
max-width: none;
font-size: 12px;
}
.mnote-mindmap-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 460px;
}
.mnote-mindmap-workspace[data-layout="floating-overlay"] {
position: relative;
display: block;
width: 100%;
height: 100%;
min-height: 560px;
overflow: hidden;
background: #f7f8fa;
}
.mnote-mindmap-canvas-layer {
position: absolute;
inset: 0;
z-index: 1;
}
.mnote-mindmap-overlay-layer {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
}
.mnote-mindmap-overlay-layer > * {
pointer-events: auto;
}
.mnote-mindmap-rust-shell-mount,
.mnote-mindmap-rust-shell {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
.mnote-mindmap-rust-shell > * {
pointer-events: auto;
}
.mnote-mindmap-workspace[data-debug-chrome="true"] {
grid-template-columns: minmax(0, 1fr) 132px;
}
.mnote-mindmap-workspace[data-debug-chrome="false"] {
grid-template-columns: minmax(0, 1fr) 172px;
}
.mnote-leptos-mindmap-runtime {
position: relative;
height: 460px;
min-height: 460px;
overflow: hidden;
background: #ffffff;
}
.mnote-mindmap-workspace[data-layout="floating-overlay"] .mnote-leptos-mindmap-runtime {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: 100%;
background: #f7f8fa;
}
.mnote-leptos-mindmap-runtime svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: auto;
}
.mnote-leptos-mindmap-runtime .smm-node {
cursor: default;
}
.mnote-mindmap-side-panel {
border-left: 1px solid rgba(148, 163, 184, 0.26);
background: #fff;
padding: 8px;
}
.mnote-mindmap-schema-sidebar {
position: absolute;
right: 18px;
top: 50%;
z-index: 4;
width: auto;
max-height: calc(100% - 130px);
transform: translateY(-50%);
display: flex;
align-items: flex-start;
gap: 12px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
padding: 0;
}
.mnote-mindmap-side-rail {
width: 60px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 8px 8px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(10px);
}
.mnote-mindmap-side-rail-handle,
.mnote-mindmap-side-restore-handle,
.mnote-mindmap-side-drawer-close {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.24);
background: rgba(248, 250, 252, 0.98);
color: #475569;
}
.mnote-mindmap-side-rail-handle {
width: 100%;
min-height: 24px;
border-radius: 8px;
}
.mnote-mindmap-side-restore-handle {
width: 22px;
min-height: 72px;
margin-top: 64px;
border-radius: 999px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
}
.mnote-mindmap-side-rail-handle:hover,
.mnote-mindmap-side-restore-handle:hover,
.mnote-mindmap-side-drawer-close:hover {
border-color: rgba(37, 99, 235, 0.36);
background: #eff6ff;
color: #1d4ed8;
}
.mnote-mindmap-side-tab {
width: 100%;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 3px;
justify-content: center;
margin-bottom: 0;
min-height: 56px;
padding: 8px 2px;
text-align: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #475569;
position: relative;
}
.mnote-mindmap-side-icon {
width: 100%;
overflow: hidden;
color: #64748b;
font-size: 13px;
text-overflow: ellipsis;
}
.mnote-mindmap-side-tab-label {
font-size: 11px;
line-height: 1.2;
}
.mnote-mindmap-side-tab[data-active="true"] {
background: #eff6ff;
color: #1d4ed8;
}
.mnote-mindmap-side-tab[data-active="true"]::before {
content: "";
position: absolute;
left: -8px;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 999px;
background: #2563eb;
}
.mnote-mindmap-side-body {
position: absolute;
right: 72px;
top: 0;
width: 300px;
margin-top: 0;
color: #64748b;
font-size: 12px;
padding: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 12px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(10px);
}
.mnote-mindmap-schema-sidebar .mnote-mindmap-side-body {
padding-top: 16px;
}
.mnote-mindmap-side-body strong,
.mnote-mindmap-side-body span {
display: block;
}
.mnote-mindmap-side-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.mnote-mindmap-side-drawer-heading {
min-width: 0;
flex: 1;
}
.mnote-mindmap-side-drawer-heading strong {
color: #0f172a;
font-size: 15px;
line-height: 1.3;
}
.mnote-mindmap-side-drawer-heading span {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.mnote-mindmap-side-drawer-close {
width: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 8px;
}
.mnote-mindmap-side-swatches {
display: flex;
gap: 5px;
margin-top: 8px;
}
.mnote-mindmap-side-swatches i {
width: 18px;
height: 18px;
border: 1px solid rgba(148, 163, 184, 0.38);
border-radius: 4px;
background: #f8fafc;
}
.mnote-mindmap-side-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 16px;
}
.mnote-mindmap-side-option {
min-height: 40px;
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: #f8fafc;
color: #334155;
font-size: 12px;
line-height: 1.3;
text-align: left;
}
.mnote-mindmap-side-option:hover {
border-color: rgba(37, 99, 235, 0.38);
background: #eff6ff;
color: #1d4ed8;
}
.mnote-mindmap-side-option-readonly {
background: #fff;
}
.mnote-mindmap-side-option-label {
color: inherit;
font-weight: 500;
}
.mnote-mindmap-side-option-description {
margin-top: 4px;
color: #64748b;
font-size: 11px;
}
.mnote-mindmap-side-option-swatch {
display: flex;
align-items: center;
gap: 10px;
}
.mnote-mindmap-side-option-swatch-chip {
width: 18px;
height: 18px;
min-width: 18px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 999px;
}
.mnote-mindmap-side-option-layout-card {
display: flex;
min-height: 86px;
flex-direction: column;
justify-content: flex-start;
gap: 8px;
}
.mnote-mindmap-side-option-preview-card {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 38px;
border-radius: 6px;
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
color: #1d4ed8;
font-size: 11px;
line-height: 1;
}
.mnote-mindmap-side-outline {
max-height: 172px;
margin: 16px 0 0;
padding-left: 14px;
overflow: auto;
color: #334155;
font-size: 11px;
line-height: 1.45;
}
.mnote-mindmap-side-outline:empty {
display: none;
}
.mnote-mindmap-side-swatches i:nth-child(2) {
background: #eef2ff;
}
.mnote-mindmap-side-swatches i:nth-child(3) {
background: #ecfeff;
}
.mnote-mindmap-bottom-bar {
border-top: 1px solid rgba(148, 163, 184, 0.26);
border-bottom: 0;
font-size: 12px;
color: #475569;
}
.mnote-mindmap-schema-navigator {
position: absolute;
right: 28px;
bottom: 22px;
z-index: 4;
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
min-height: 40px;
padding: 6px 8px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 6px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(10px);
}
.mnote-mindmap-schema-navigator [data-testid="mindmap-schema-navigator-stats"] {
margin-right: auto;
}
.mnote-mindmap-schema-navigator-group {
display: inline-flex;
align-items: center;
gap: 6px;
}
.mnote-mindmap-schema-navigator-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
min-width: 30px;
padding: 0;
}
.mnote-mindmap-schema-navigator-button[data-active="true"] {
border-color: rgba(37, 99, 235, 0.32);
background: rgba(219, 234, 254, 0.94);
color: #1d4ed8;
}
.mnote-mindmap-schema-navigator-zoom {
width: 62px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.mnote-mindmap-schema-navigator-search-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
}
.mnote-mindmap-schema-navigator-search-wrap input {
width: 136px;
}
.mnote-mindmap-minimap {
position: absolute;
right: 28px;
bottom: 76px;
z-index: 4;
width: 180px;
height: 118px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 6px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 16px rgba(15, 23, 42, 0.08);
pointer-events: auto;
}
.mnote-mindmap-minimap-map {
position: absolute;
inset: 12px;
}
.mnote-mindmap-minimap-map span {
position: absolute;
display: block;
width: 42px;
height: 14px;
border-radius: 4px;
background: #dbeafe;
}
.mnote-mindmap-minimap-map span:nth-child(1) {
left: 54px;
top: 8px;
background: #bfdbfe;
}
.mnote-mindmap-minimap-map span:nth-child(2) {
left: 22px;
top: 52px;
background: #dcfce7;
}
.mnote-mindmap-minimap-map span:nth-child(3) {
right: 22px;
top: 52px;
background: #fde68a;
}
.mnote-mindmap-minimap-viewport {
position: absolute;
left: 38px;
top: 30px;
width: 104px;
height: 62px;
border: 2px solid rgba(37, 99, 235, 0.75);
border-radius: 5px;
background: rgba(37, 99, 235, 0.08);
}
.mnote-mindmap-context-menu {
position: absolute;
z-index: 8;
min-width: 132px;
padding: 6px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 6px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14);
pointer-events: auto;
}
.mnote-mindmap-context-menu button {
display: block;
width: 100%;
min-height: 26px;
padding: 4px 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 12px;
text-align: left;
}
.mnote-mindmap-context-menu button:hover {
background: #eff6ff;
color: #1d4ed8;
}
.mnote-mindmap-count {
position: absolute;
left: 28px;
bottom: 30px;
z-index: 4;
display: flex;
gap: 18px;
color: #64748b;
font-size: 12px;
pointer-events: auto;
}
.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-tertiary-submenu {
position: absolute;
left: calc(100% + 8px);
top: 0;
width: 206px;
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: 140;
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;
}
.block-layout-outline {
position: absolute;
left: 56px;
right: 34px;
min-height: 30px;
border: 1px dashed rgba(148, 163, 184, 0.95);
border-radius: 4px;
background: rgba(255, 71, 71, 0.06);
pointer-events: none;
z-index: 12;
}
.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;
}
.image-floating-toolbar {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, calc(-100% - 12px));
z-index: 122;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 4px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.98);
color: #111827;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(16px);
pointer-events: auto;
}
.image-toolbar-btn {
min-width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border: none;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.image-toolbar-btn:hover,
.image-toolbar-btn:focus-visible,
.image-toolbar-btn[data-active="true"] {
background: rgba(255, 229, 229, 0.95);
outline: none;
}
.image-toolbar-btn:disabled {
color: #a1a1aa;
cursor: not-allowed;
opacity: 0.55;
background: transparent;
}
.image-toolbar-separator {
width: 1px;
align-self: stretch;
min-height: 22px;
background: rgba(148, 163, 184, 0.3);
}
.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: fixed;
top: 24px;
left: 24px;
z-index: 130;
width: min(316px, calc(100vw - 16px));
max-height: min(430px, calc(100vh - 16px));
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;
}
.editor-surface .ProseMirror img[src] {
display: block;
max-width: 100%;
height: auto;
margin: 8px 0;
border-radius: 6px;
}
.editor-surface .ProseMirror img[data-align="center"] {
margin-left: auto;
margin-right: auto;
}
.editor-surface .ProseMirror img[data-align="right"] {
margin-left: auto;
margin-right: 0;
}
.editor-surface .ProseMirror img.ProseMirror-selectednode {
outline: 2px solid rgba(255, 71, 71, 0.78);
outline-offset: 3px;
box-shadow: 0 0 0 5px rgba(255, 71, 71, 0.12);
}
.editor-surface .ProseMirror .tiptap-table-of-contents-node {
display: block;
margin: 12px 0;
padding: 10px 12px;
border: 1px solid rgba(229, 231, 235, 0.95);
border-radius: 6px;
background: #fff;
color: #374151;
}
.editor-surface .ProseMirror .tiptap-table-of-contents-node.ProseMirror-selectednode {
outline: 2px solid rgba(255, 71, 71, 0.62);
outline-offset: 2px;
}
.editor-surface .ProseMirror [data-block-id]:target {
outline: 2px solid #2563eb;
outline-offset: 3px;
background: rgba(37, 99, 235, 0.08);
scroll-margin-top: 72px;
transition: background 0.18s ease, outline-color 0.18s ease;
}
.tiptap-table-of-contents-title-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 24px;
gap: 8px;
}
.tiptap-table-of-contents-title {
font-size: 14px;
line-height: 20px;
font-weight: 600;
color: #4b5563;
}
.tiptap-table-of-contents-title-toggle {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #9ca3af;
font-size: 12px;
cursor: pointer;
}
.tiptap-table-of-contents-title-toggle:hover {
background: rgba(17, 24, 39, 0.06);
color: #4b5563;
}
.tiptap-table-of-contents-list {
display: grid;
gap: 2px;
margin-top: 6px;
}
.tiptap-table-of-contents-item {
display: block;
min-height: 24px;
padding: 2px 6px;
border-radius: 4px;
color: #6b7280;
font-size: 14px;
line-height: 20px;
text-decoration: none;
}
.tiptap-table-of-contents-item[data-depth="2"] { padding-left: 20px; }
.tiptap-table-of-contents-item[data-depth="3"] { padding-left: 34px; }
.tiptap-table-of-contents-item[data-depth="4"] { padding-left: 48px; }
.tiptap-table-of-contents-item[data-depth="5"] { padding-left: 62px; }
.tiptap-table-of-contents-item[data-depth="6"] { padding-left: 76px; }
.tiptap-table-of-contents-item:hover {
background: rgba(17, 24, 39, 0.05);
color: #374151;
}
.tiptap-table-of-contents-empty {
margin-top: 4px;
color: #9ca3af;
font-size: 14px;
line-height: 22px;
}
.editor-surface .ProseMirror .tableWrapper {
width: 100%;
overflow-x: auto;
margin: 8px 0;
}
.editor-surface .ProseMirror table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin: 8px 0;
}
.editor-surface .ProseMirror .tableWrapper table {
margin: 0;
}
.editor-surface .ProseMirror td,
.editor-surface .ProseMirror th {
position: relative;
min-width: 84px;
height: 32px;
padding: 5px 8px;
border: 1px solid rgba(148, 163, 184, 0.72);
vertical-align: top;
}
.editor-surface .ProseMirror th {
background: rgba(248, 250, 252, 0.96);
font-weight: 600;
}
.editor-surface .ProseMirror table[data-hidden-borders="true"] td,
.editor-surface .ProseMirror table[data-hidden-borders="true"] th {
border-width: 0;
}
.editor-surface .ProseMirror .selectedCell::after {
content: "";
position: absolute;
inset: 0;
background: rgba(59, 130, 246, 0.12);
pointer-events: none;
}
.editor-surface .ProseMirror .column-resize-handle {
position: absolute;
top: 0;
right: -4px;
bottom: -1px;
width: 8px;
background: rgba(239, 68, 68, 0.34);
cursor: col-resize;
pointer-events: none;
z-index: 4;
}
.editor-surface .ProseMirror .column-resize-dragging {
background: rgba(255, 71, 71, 0.08);
}
.editor-surface .ProseMirror.resize-cursor,
.editor-surface .ProseMirror.resize-cursor * {
cursor: col-resize !important;
}
.table-controls {
position: absolute;
z-index: 118;
pointer-events: none;
}
.table-aux-btn {
position: absolute;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(148, 163, 184, 0.95);
font: inherit;
font-size: 15px;
line-height: 1;
cursor: pointer;
pointer-events: auto;
}
.table-aux-btn:hover,
.table-aux-btn:focus-visible,
.table-aux-btn[data-active="true"] {
background: rgba(255, 229, 229, 0.95);
color: rgba(107, 114, 128, 0.96);
outline: none;
}
.table-selection-overlay {
position: absolute;
border-radius: 4px;
background: rgba(255, 71, 71, 0.12);
outline: 1px solid rgba(255, 71, 71, 0.18);
pointer-events: none;
}
.table-toolbar {
position: absolute;
top: 34px;
right: 44px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 8px;
background: #fff;
color: #111827;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.16);
z-index: 120;
pointer-events: auto;
}
.table-toolbar-btn {
min-width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border: none;
border-radius: 5px;
background: transparent;
color: #111827;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.table-toolbar-btn:hover,
.table-toolbar-btn:focus-visible {
background: rgba(241, 245, 249, 0.95);
outline: none;
}
.table-toolbar-separator {
align-self: stretch;
width: 1px;
min-height: 22px;
background: rgba(148, 163, 184, 0.3);
}
.table-options-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 160px;
display: grid;
gap: 2px;
padding: 6px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 8px;
background: #fff;
color: #111827;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16);
z-index: 125;
}
.table-option-btn {
width: 100%;
min-height: 30px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 8px;
border: none;
border-radius: 5px;
background: transparent;
color: #111827;
font: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
}
.table-option-btn:hover,
.table-option-btn:focus-visible {
background: rgba(241, 245, 249, 0.95);
outline: none;
}
.table-option-switch {
position: relative;
width: 28px;
height: 16px;
flex: 0 0 auto;
border-radius: 999px;
background: #d1d5db;
transition: background .12s ease;
}
.table-option-switch::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.2);
transition: transform .12s ease;
}
.table-option-btn[aria-checked="true"] .table-option-switch {
background: #9ca3af;
}
.table-option-btn[aria-checked="true"] .table-option-switch::after {
transform: translateX(12px);
}
.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;
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
min-height: 28px;
margin: 1px 0;
padding: 2px 4px;
border: 0;
border-radius: 4px;
color: #37352f;
font-weight: 500;
line-height: 1.4;
text-decoration: none;
background: transparent;
box-shadow: none;
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before {
content: "";
width: 18px;
height: 18px;
flex: 0 0 auto;
border-radius: 4px;
background: var(--attachment-icon-bg, #e9ecef);
border: 1px solid var(--attachment-icon-border, rgba(55, 53, 47, 0.14));
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row::after {
content: "◉";
flex: 0 0 auto;
margin-left: 4px;
color: #9ca3af;
font-size: 11px;
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row:hover {
background: rgba(55, 53, 47, 0.06);
text-decoration: none;
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-word {
--attachment-icon-bg: #4f82ff;
--attachment-icon-border: #3366d6;
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-ppt {
--attachment-icon-bg: #ea581f;
--attachment-icon-border: #cf4817;
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-sheet {
--attachment-icon-bg: #2f9e44;
--attachment-icon-border: #25813a;
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-pdf {
--attachment-icon-bg: #ef4444;
--attachment-icon-border: #dc2626;
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-file {
--attachment-icon-bg: #9ca3af;
--attachment-icon-border: #6b7280;
}
.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%;
}
.mnote-mindmap-workspace {
grid-template-columns: minmax(0, 1fr);
}
.mnote-mindmap-side-panel {
border-left: 0;
border-top: 1px solid rgba(148, 163, 184, 0.26);
}
.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,
SimpleTable,
Mindmap,
Image,
UploadAttachment,
Toc,
}
#[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,
}
#[derive(Clone, Copy)]
struct FoldedHeadingAction {
level: u8,
id: &'static str,
icon: &'static str,
label: &'static str,
}
const FOLDED_HEADING_ACTIONS: [FoldedHeadingAction; 4] = [
FoldedHeadingAction {
level: 1,
id: "1",
icon: "H1",
label: "折叠主标题",
},
FoldedHeadingAction {
level: 2,
id: "2",
icon: "H2",
label: "折叠大标题",
},
FoldedHeadingAction {
level: 3,
id: "3",
icon: "H3",
label: "折叠中标题",
},
FoldedHeadingAction {
level: 4,
id: "4",
icon: "H4",
label: "折叠小标题",
},
];
const SLASH_ACTIONS: [SlashAction; 22] = [
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",
},
SlashAction {
kind: SlashActionKind::SimpleTable,
id: "simple-table",
category: "进阶块列表",
icon: "",
label: "简单表格",
description: "插入基础表格",
shortcut: "/jdbg",
},
SlashAction {
kind: SlashActionKind::Mindmap,
id: "mindmap",
category: "进阶块列表",
icon: "",
label: "思维导图",
description: "插入 kernel projection 导图块",
shortcut: "/dt",
},
SlashAction {
kind: SlashActionKind::Image,
id: "image",
category: "媒体与附件",
icon: "",
label: "图片",
description: "上传并插入图片",
shortcut: "/tp",
},
SlashAction {
kind: SlashActionKind::UploadAttachment,
id: "upload-attachment",
category: "媒体与附件",
icon: "",
label: "上传附件",
description: "上传 OfficePDF 或其他文件",
shortcut: "/fj",
},
SlashAction {
kind: SlashActionKind::Toc,
id: "toc",
category: "进阶块列表",
icon: "",
label: "页面目录",
description: "根据当前标题生成目录",
shortcut: "/toc",
},
];
#[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 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 runtime_editor_instance_id_uses_mount_id_when_available() {
assert_eq!(
runtime_editor_instance_id(Some(7)),
"mnote-leptos-tiptap-spike-7"
);
}
#[test]
fn runtime_editor_instance_id_falls_back_for_standalone_mode() {
assert_eq!(
runtime_editor_instance_id(None),
"mnote-leptos-tiptap-spike-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)
);
}
#[test]
fn standalone_mindmap_object_uses_isolated_draft_identity() {
let object = RuntimeStandaloneObject {
kind: Some("mindmap".to_string()),
document_id: Some("doc-a".to_string()),
mindmap_id: Some("mind-a".to_string()),
};
let identity = persisted_mindmap_object_identity(Some(&object)).expect("identity");
assert_eq!(
persisted_document_storage_key(&identity),
"mnote.leptos-tiptap-spike.document:mindmap-object:doc-a:mind-a"
);
}
#[test]
fn mindmap_empty_paragraph_has_textblock_boundary_size() {
let node = json!({
"type": "paragraph",
"attrs": {
"mnoteBlockType": "mindmap",
"mindmapId": "思维导图123456.json",
"rootNodeId": "root",
"projectionVersion": 1
}
});
assert_eq!(prosemirror_node_size(&node), Some(2));
let document = json!({
"type": "doc",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": ""}]},
node,
{"type": "paragraph", "content": [{"type": "text", "text": ""}]}
]
});
assert_eq!(
top_level_block_boundary_position(&document, 1, true),
Some(3)
);
assert_eq!(
top_level_block_boundary_position(&document, 1, false),
Some(5)
);
}
#[test]
fn slash_menu_css_uses_viewport_overlay_layer() {
assert!(STYLE.contains(".slash-menu"));
assert!(STYLE.contains("position: fixed;"));
assert!(STYLE.contains("z-index: 130;"));
assert!(!STYLE.contains("z-index: 11;"));
}
}
#[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>,
#[serde(default)]
standalone_object: Option<RuntimeStandaloneObject>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RuntimeStandaloneObject {
kind: Option<String>,
document_id: Option<String>,
mindmap_id: Option<String>,
}
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>,
}
struct MountedMindmapShell {
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 MOUNTED_MINDMAP_SHELLS: std::cell::RefCell<HashMap<u32, MountedMindmapShell>> = 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_editor_instance_id(mount_id: Option<u32>) -> String {
mount_id
.filter(|value| *value > 0)
.map(|value| format!("mnote-leptos-tiptap-spike-{value}"))
.unwrap_or_else(|| "mnote-leptos-tiptap-spike-standalone".to_string())
}
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 editor_block_by_id(block_id: &str) -> Option<Element> {
find_mnote_block_anchor(block_id)
}
fn current_page_anchor_url(block_id: &str) -> Option<String> {
let win = window()?;
let location = win.location();
let origin = location.origin().ok()?;
let pathname = location.pathname().ok()?;
let search = location.search().ok().unwrap_or_default();
Some(format!("{}{}{}#{}", origin, pathname, search, block_id))
}
fn scroll_mnote_block_anchor(block_id: &str) -> bool {
let Some(block) = editor_block_by_id(block_id) else {
return false;
};
let _ = block.scroll_into_view_with_bool(true);
true
}
fn current_location_hash_block_id() -> Option<String> {
window()
.and_then(|win| win.location().hash().ok())
.map(|hash| hash.trim_start_matches('#').trim().to_string())
.filter(|hash| !hash.is_empty())
}
fn scroll_mnote_block_anchor_from_hash() -> bool {
current_location_hash_block_id()
.as_deref()
.map(scroll_mnote_block_anchor)
.unwrap_or(false)
}
fn schedule_scroll_mnote_block_anchor_from_hash() {
schedule_scroll_mnote_block_anchor_from_hash_retry(60);
}
fn schedule_scroll_mnote_block_anchor_from_hash_retry(remaining: u8) {
let Some(win) = window() else {
return;
};
let callback = Closure::<dyn FnMut()>::new(move || {
let _ = scroll_mnote_block_anchor_from_hash();
if remaining > 1 {
schedule_scroll_mnote_block_anchor_from_hash_retry(remaining - 1);
}
});
let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
callback.as_ref().unchecked_ref(),
120,
);
callback.forget();
}
fn current_viewport_scroll() -> Option<(f64, f64)> {
let win = window()?;
let x = win.scroll_x().ok()?;
let y = win.scroll_y().ok()?;
Some((x, y))
}
fn restore_viewport_scroll(x: f64, y: f64) {
if let Some(win) = window() {
win.scroll_to_with_x_and_y(x, y);
}
}
fn schedule_restore_viewport_scroll(x: f64, y: f64, remaining: u8) {
restore_viewport_scroll(x, y);
let Some(win) = window() else {
return;
};
if remaining == 0 {
return;
}
let callback = Closure::<dyn FnMut()>::new(move || {
restore_viewport_scroll(x, y);
if remaining > 1 {
schedule_restore_viewport_scroll(x, y, remaining - 1);
}
});
let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
callback.as_ref().unchecked_ref(),
80,
);
callback.forget();
}
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_to_target(target: &EventTarget, payload: &ReadyPayload) {
dispatch_custom_event_to_target(target, READY_EVENT, payload);
}
fn dispatch_change_event(payload: &ChangePayload) {
dispatch_runtime_event(CHANGE_EVENT, payload);
}
fn dispatch_change_event_to_target(target: &EventTarget, payload: &ChangePayload) {
dispatch_custom_event_to_target(target, CHANGE_EVENT, payload);
}
fn dispatch_state_event(payload: &StatePayload) {
dispatch_runtime_event(STATE_EVENT, payload);
}
fn dispatch_state_event_to_target(target: &EventTarget, payload: &StatePayload) {
dispatch_custom_event_to_target(target, STATE_EVENT, payload);
}
fn dispatch_status_event(payload: &HostStatusPayload) {
dispatch_runtime_event(STATUS_EVENT, payload);
}
fn dispatch_status_event_to_target(target: &EventTarget, payload: &HostStatusPayload) {
dispatch_custom_event_to_target(target, STATUS_EVENT, payload);
}
fn dispatch_selection_event_to_target(target: &EventTarget, payload: &SelectionPayload) {
dispatch_custom_event_to_target(target, 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))
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellAction {
id: String,
label: String,
#[serde(default)]
long_label: Option<String>,
icon: String,
#[serde(default)]
icon_key: Option<String>,
#[serde(default)]
priority: Option<u32>,
#[serde(default)]
overflow_group: Option<String>,
#[serde(default)]
cluster: Option<String>,
disabled: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellToolbarGroup {
id: String,
label: String,
actions: Vec<MindmapShellAction>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellSidebarPanel {
id: String,
kind: String,
label: String,
icon: String,
active: bool,
body_title: String,
body_caption: String,
#[serde(default)]
options: Vec<MindmapShellSidebarOption>,
#[serde(default)]
body_items: Vec<String>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellSidebarOption {
id: String,
label: String,
action_id: Option<String>,
value: Value,
control_type: String,
#[serde(default)]
preview: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
readonly: bool,
compat_path: Option<String>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellNavigator {
word_count: u32,
node_count: u32,
zoom_percent: u32,
readonly: bool,
#[serde(default)]
minimap_open: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellToolbarOverflowState {
available_width: Option<f64>,
#[serde(default)]
visible_action_ids: Vec<String>,
#[serde(default)]
overflow_action_ids: Vec<String>,
#[serde(default)]
more_open: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellFullscreenState {
mode: String,
is_fullscreen: bool,
target: String,
#[serde(default = "default_true")]
api_available: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellSidebarState {
trigger_visible: bool,
panel_open: bool,
active_panel_id: Option<String>,
drawer_width: u32,
collapsed_by_toggle: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellNavigatorState {
search_open: bool,
minimap_open: bool,
readonly: bool,
zoom_percent: u32,
mouse_behavior: String,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellInteractionState {
chrome_visibility: String,
toolbar_overflow: MindmapShellToolbarOverflowState,
fullscreen: MindmapShellFullscreenState,
sidebar: MindmapShellSidebarState,
navigator: MindmapShellNavigatorState,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellOptions {
mindmap_id: String,
toolbar_groups: Vec<MindmapShellToolbarGroup>,
sidebar_panels: Vec<MindmapShellSidebarPanel>,
navigator: MindmapShellNavigator,
shell: MindmapShellInteractionState,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellActionPayload<'a> {
action_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<&'a Value>,
#[serde(skip_serializing_if = "Option::is_none")]
compat_path: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<&'a str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellPanelPayload<'a> {
event: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
panel_id: Option<&'a str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellMinimapPayload {
open: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellZoomPayload {
percent: u32,
}
fn default_true() -> bool {
true
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MindmapShellToolbarOverflowPayload {
more_open: bool,
}
fn dispatch_mindmap_shell_event<T>(target: &EventTarget, event_name: &'static str, payload: &T)
where
T: Serialize,
{
let Ok(detail_value) = serde_wasm_bindgen::to_value(payload) 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 render_mindmap_toolbar_action(
action: MindmapShellAction,
class_name: &'static str,
source: &'static str,
action_target: EventTarget,
) -> impl IntoView {
let action_id = action.id.clone();
let action_label_title = action
.long_label
.clone()
.filter(|label| !label.is_empty())
.unwrap_or_else(|| action.label.clone());
let action_label_aria = action_label_title.clone();
let action_label_text = action.label.clone();
let action_icon_text = action.icon.clone();
let action_icon_key = action.icon_key.clone().unwrap_or_default();
let action_overflow_group = action.overflow_group.clone().unwrap_or_default();
let action_cluster = action.cluster.clone().unwrap_or_default();
let action_priority = action.priority.unwrap_or(u32::MAX).to_string();
let action_disabled = action.disabled;
view! {
<button
type="button"
class=class_name
data-testid=format!("mindmap-schema-toolbar-action-{}", action_id)
data-mindmap-action-id=action.id.clone()
data-icon-key=action_icon_key
data-overflow-group=action_overflow_group
data-toolbar-cluster=action_cluster
data-priority=action_priority
title=action_label_title
aria-label=action_label_aria
disabled=action_disabled
on:click=move |_| {
dispatch_mindmap_shell_event(
&action_target,
MINDMAP_SHELL_ACTION_EVENT,
&MindmapShellActionPayload {
action_id: &action_id,
value: None,
compat_path: None,
source: Some(source),
},
);
}
>
<span class="mnote-mindmap-tool-icon" aria-hidden="true">{action_icon_text}</span>
<span class="mnote-mindmap-tool-label">{action_label_text}</span>
</button>
}
}
fn render_mindmap_sidebar_option(
option: MindmapShellSidebarOption,
action_target: EventTarget,
) -> impl IntoView {
let option_id = option.id.clone();
let option_label = option.label.clone();
let option_control_type = option.control_type.clone();
let action_id = option.action_id.clone();
let option_value = option.value.clone();
let compat_path = option.compat_path.clone();
let option_preview = option.preview.clone().unwrap_or_default();
let option_description = option.description.clone().unwrap_or_default();
let option_readonly = option.readonly;
let option_action_id_attr = action_id.clone().unwrap_or_default();
if option_readonly || action_id.is_none() {
return view! {
<div
class="mnote-mindmap-side-option mnote-mindmap-side-option-readonly"
data-testid=format!("mindmap-schema-sidebar-option-{}", option_id)
data-mindmap-sidebar-option-id=option.id
data-control-type=option_control_type.clone()
data-readonly="true"
>
<span class="mnote-mindmap-side-option-label">{option_label}</span>
{if option_description.is_empty() {
().into_any()
} else {
view! { <span class="mnote-mindmap-side-option-description">{option_description}</span> }.into_any()
}}
</div>
}
.into_any();
}
let button_class = if option_control_type == "layoutCard" {
"mnote-mindmap-side-option mnote-mindmap-side-option-layout-card"
} else if option_control_type == "swatch" {
"mnote-mindmap-side-option mnote-mindmap-side-option-swatch"
} else if option_control_type == "treeItem" {
"mnote-mindmap-side-option mnote-mindmap-side-option-tree-item"
} else {
"mnote-mindmap-side-option"
};
view! {
<button
type="button"
class=button_class
data-testid=format!("mindmap-schema-sidebar-option-{}", option_id)
data-mindmap-sidebar-option-id=option.id
data-mindmap-action-id=option_action_id_attr
data-control-type=option_control_type.clone()
on:click=move |_| {
if let Some(action_id) = action_id.as_deref() {
dispatch_mindmap_shell_event(
&action_target,
MINDMAP_SHELL_ACTION_EVENT,
&MindmapShellActionPayload {
action_id,
value: Some(&option_value),
compat_path: compat_path.as_deref(),
source: Some("sidebar"),
},
);
}
}
>
{if option_control_type == "swatch" {
view! {
<span class="mnote-mindmap-side-option-swatch-chip" style=format!("background:{};", option_preview.clone())></span>
}
.into_any()
} else if option_control_type == "layoutCard" {
view! {
<span class="mnote-mindmap-side-option-preview-card">{option_preview.clone()}</span>
}
.into_any()
} else {
().into_any()
}}
<span class="mnote-mindmap-side-option-label">{option_label}</span>
{if option_description.is_empty() {
().into_any()
} else {
view! { <span class="mnote-mindmap-side-option-description">{option_description}</span> }.into_any()
}}
</button>
}
.into_any()
}
#[component]
fn MindmapShell(options: MindmapShellOptions, event_target: EventTarget) -> impl IntoView {
let toolbar_groups = options.toolbar_groups.clone();
let sidebar_panels = options.sidebar_panels.clone();
let navigator = options.navigator.clone();
let shell = options.shell.clone();
let minimap_open = shell.navigator.minimap_open;
let search_open = shell.navigator.search_open;
let sidebar_panel_open = shell.sidebar.panel_open;
let sidebar_trigger_visible = shell.sidebar.trigger_visible;
let active_panel_id = shell.sidebar.active_panel_id.clone();
let sidebar_drawer_width = shell.sidebar.drawer_width.to_string();
let chrome_visibility = shell.chrome_visibility.clone();
let fullscreen_mode = shell.fullscreen.mode.clone();
let fullscreen_target = shell.fullscreen.target.clone();
let navigator_mouse_behavior = shell.navigator.mouse_behavior.clone();
let toolbar_available_width = shell
.toolbar_overflow
.available_width
.map(|width| width.round().to_string())
.unwrap_or_else(|| "".to_string());
let chrome_visible = shell.chrome_visibility == "visible";
let toolbar_visible_actions = shell.toolbar_overflow.visible_action_ids.join(",");
let toolbar_overflow_actions = shell.toolbar_overflow.overflow_action_ids.join(",");
let sidebar_drawer_width_value = shell.sidebar.drawer_width;
let navigator_right_offset = if chrome_visible && sidebar_panel_open && sidebar_trigger_visible
{
sidebar_drawer_width_value + 112
} else if chrome_visible && sidebar_trigger_visible {
108
} else {
28
};
let navigator_right_style = format!("right: {}px;", navigator_right_offset);
let minimap_right_style = format!("right: {}px;", navigator_right_offset);
let mut primary_toolbar_actions: Vec<MindmapShellAction> = Vec::new();
let mut overflow_toolbar_actions: Vec<MindmapShellAction> = Vec::new();
let mut file_toolbar_actions: Vec<MindmapShellAction> = Vec::new();
for group in toolbar_groups.into_iter() {
match group.id.as_str() {
"overflow" => overflow_toolbar_actions.extend(group.actions),
"file" => file_toolbar_actions.extend(group.actions),
_ => primary_toolbar_actions.extend(group.actions),
}
}
primary_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX));
overflow_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX));
file_toolbar_actions.sort_by_key(|action| action.priority.unwrap_or(u32::MAX));
let has_toolbar_overflow = !overflow_toolbar_actions.is_empty();
let toolbar_more_open = shell.toolbar_overflow.more_open && has_toolbar_overflow;
let action_target = event_target.clone();
let panel_target = event_target;
view! {
<div
class="mnote-mindmap-rust-shell"
data-testid="mindmap-rust-shell"
data-ui-shell-source="leptos-rust-shell"
data-mnote-mindmap-id=options.mindmap_id
data-chrome-visibility=chrome_visibility
data-toolbar-available-width=toolbar_available_width
data-toolbar-visible-actions=toolbar_visible_actions
data-toolbar-overflow-actions=toolbar_overflow_actions
data-toolbar-more-open=toolbar_more_open.to_string()
data-fullscreen-mode=fullscreen_mode
data-fullscreen-active=shell.fullscreen.is_fullscreen.to_string()
data-fullscreen-target=fullscreen_target
data-fullscreen-api-available=shell.fullscreen.api_available.to_string()
data-sidebar-trigger-visible=sidebar_trigger_visible.to_string()
data-sidebar-panel-open=sidebar_panel_open.to_string()
data-sidebar-active-panel=active_panel_id.unwrap_or_default()
data-sidebar-drawer-width=sidebar_drawer_width
data-sidebar-collapsed-by-toggle=shell.sidebar.collapsed_by_toggle.to_string()
data-navigator-search-open=shell.navigator.search_open.to_string()
data-navigator-minimap-open=shell.navigator.minimap_open.to_string()
data-navigator-readonly=shell.navigator.readonly.to_string()
data-navigator-zoom-percent=shell.navigator.zoom_percent.to_string()
data-navigator-mouse-behavior=navigator_mouse_behavior
>
{if chrome_visible {
view! { <div
class="mnote-mindmap-command-toolbar mnote-mindmap-schema-toolbar"
data-testid="mindmap-schema-toolbar"
data-ui-source="leptos-rust-shell"
data-schema-source="mindmapDefaultUiSchema"
>
<div class="mnote-mindmap-toolbar-primary" data-testid="mindmap-schema-toolbar-groups">
<div class="mnote-mindmap-toolbar-cluster mnote-mindmap-toolbar-cluster-main" data-testid="mindmap-schema-toolbar-cluster-main">
{primary_toolbar_actions
.into_iter()
.map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone()))
.collect_view()}
</div>
{if has_toolbar_overflow {
let overflow_toggle_target = action_target.clone();
let overflow_menu_actions = overflow_toolbar_actions.clone();
view! {
<div class="mnote-mindmap-toolbar-more" data-testid="mindmap-schema-toolbar-more">
<button
type="button"
class="mnote-mindmap-tool mnote-mindmap-toolbar-more-button"
data-testid="mindmap-schema-toolbar-action-more"
data-mindmap-action-id="more"
data-open=toolbar_more_open.to_string()
aria-haspopup="menu"
aria-expanded=toolbar_more_open.to_string()
title="更多"
aria-label="更多"
on:click=move |_| {
dispatch_mindmap_shell_event(
&overflow_toggle_target,
MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT,
&MindmapShellToolbarOverflowPayload { more_open: !toolbar_more_open },
);
}
>
<span class="mnote-mindmap-tool-icon" aria-hidden="true">""</span>
<span class="mnote-mindmap-tool-label">"更多"</span>
</button>
{if toolbar_more_open {
view! {
<div class="mnote-mindmap-toolbar-more-menu" data-testid="mindmap-schema-toolbar-more-menu" role="menu">
{overflow_menu_actions
.into_iter()
.map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool mnote-mindmap-toolbar-more-item", "toolbar", action_target.clone()))
.collect_view()}
</div>
}.into_any()
} else {
().into_any()
}}
</div>
}.into_any()
} else {
().into_any()
}}
<div class="mnote-mindmap-toolbar-cluster mnote-mindmap-toolbar-cluster-file" data-testid="mindmap-schema-toolbar-cluster-file">
{file_toolbar_actions
.into_iter()
.map(|action| render_mindmap_toolbar_action(action, "mnote-mindmap-tool", "toolbar", action_target.clone()))
.collect_view()}
</div>
</div>
</div> }.into_any()
} else {
().into_any()
}}
{if chrome_visible {
view! { <aside
class="mnote-mindmap-side-panel mnote-mindmap-schema-sidebar"
data-testid="mindmap-schema-sidebar"
data-ui-source="leptos-rust-shell"
data-schema-source="mindmapDefaultUiSchema"
data-trigger-visible=sidebar_trigger_visible.to_string()
data-panel-open=sidebar_panel_open.to_string()
data-collapsed-by-toggle=shell.sidebar.collapsed_by_toggle.to_string()
>
{if sidebar_trigger_visible {
view! {
<div class="mnote-mindmap-side-rail" data-testid="mindmap-schema-sidebar-rail">
<button
type="button"
class="mnote-mindmap-side-rail-handle"
data-testid="mindmap-schema-sidebar-hide-handle"
aria-label="隐藏侧栏"
title="隐藏侧栏"
on:click={
let panel_target = panel_target.clone();
move |_| {
dispatch_mindmap_shell_event(
&panel_target,
MINDMAP_SHELL_PANEL_EVENT,
&MindmapShellPanelPayload {
event: "hideTrigger",
panel_id: None,
},
);
}
}
>
""
</button>
{sidebar_panels
.iter()
.cloned()
.map(|panel| {
let panel_id = panel.id.clone();
let panel_kind = panel.kind.clone();
let panel_target = panel_target.clone();
view! {
<button
type="button"
class="mnote-mindmap-side-tab"
data-testid=format!("mindmap-schema-sidebar-tab-{}", panel_id)
data-mindmap-sidebar-panel-id=panel.id.clone()
data-sidebar-kind=panel_kind
data-active=panel.active.to_string()
on:click=move |_| {
dispatch_mindmap_shell_event(
&panel_target,
MINDMAP_SHELL_PANEL_EVENT,
&MindmapShellPanelPayload {
event: "togglePanel",
panel_id: Some(&panel_id),
},
);
}
>
<span class="mnote-mindmap-side-icon">{panel.icon}</span>
<span class="mnote-mindmap-side-tab-label">{panel.label}</span>
</button>
}
})
.collect_view()}
</div>
}
.into_any()
} else {
view! {
<button
type="button"
class="mnote-mindmap-side-restore-handle"
data-testid="mindmap-schema-sidebar-restore-handle"
aria-label="恢复侧栏"
title="恢复侧栏"
on:click={
let panel_target = panel_target.clone();
move |_| {
dispatch_mindmap_shell_event(
&panel_target,
MINDMAP_SHELL_PANEL_EVENT,
&MindmapShellPanelPayload {
event: "restoreTrigger",
panel_id: None,
},
);
}
}
>
""
</button>
}
.into_any()
}}
{if sidebar_panel_open {
sidebar_panels
.iter()
.find(|panel| panel.active)
.or_else(|| sidebar_panels.first())
.cloned()
.map(|panel| view! {
<div
class="mnote-mindmap-side-body mnote-mindmap-side-drawer"
data-testid="mindmap-schema-sidebar-drawer"
data-sidebar-panel-id=panel.id.clone()
data-sidebar-kind=panel.kind.clone()
style=format!("width: {}px;", sidebar_drawer_width_value)
>
<div class="mnote-mindmap-side-drawer-header">
<div class="mnote-mindmap-side-drawer-heading">
<strong data-testid="mindmap-schema-sidebar-title">{panel.body_title}</strong>
<span>{panel.body_caption}</span>
</div>
<button
type="button"
class="mnote-mindmap-side-drawer-close"
data-testid="mindmap-schema-sidebar-close"
aria-label="关闭侧栏"
title="关闭侧栏"
on:click={
let panel_target = panel_target.clone();
move |_| {
dispatch_mindmap_shell_event(
&panel_target,
MINDMAP_SHELL_PANEL_EVENT,
&MindmapShellPanelPayload {
event: "closeDrawer",
panel_id: None,
},
);
}
}
>
"×"
</button>
</div>
<div class="mnote-mindmap-side-options" data-testid=format!("mindmap-schema-sidebar-options-{}", panel.id)>
{panel.options
.into_iter()
.map(|option| render_mindmap_sidebar_option(option, action_target.clone()))
.collect_view()}
</div>
<ol class="mnote-mindmap-side-outline" data-testid=format!("mindmap-schema-sidebar-outline-{}", panel.id)>
{panel.body_items
.into_iter()
.map(|item| view! { <li>{item}</li> })
.collect_view()}
</ol>
</div>
}.into_any())
.unwrap_or_else(|| ().into_any())
} else {
().into_any()
}}
</aside> }.into_any()
} else {
().into_any()
}}
<div
class="mnote-mindmap-count"
data-testid="mindmap-schema-count"
data-ui-source="leptos-rust-shell"
data-schema-source="mindmapDefaultUiSchema"
>
<span data-testid="mindmap-schema-navigator-stats">{format!("字数 {}", navigator.word_count)}</span>
<span>{format!("节点 {}", navigator.node_count)}</span>
</div>
{if chrome_visible {
view! { <div
class="mnote-mindmap-bottom-bar mnote-mindmap-schema-navigator"
data-testid="mindmap-schema-navigator"
data-ui-source="leptos-rust-shell"
data-schema-source="mindmapDefaultUiSchema"
style=navigator_right_style
>
<div class="mnote-mindmap-schema-navigator-group">
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-centerRoot" data-mindmap-action-id="centerRoot" title="回根节点" aria-label="回根节点" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "centerRoot",
value: None,
compat_path: None,
source: None,
})
}><span class="material-symbols-outlined" data-icon="home" aria-hidden="true"></span></button>
<div class="mnote-mindmap-schema-navigator-search-wrap">
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-search" data-mindmap-action-id="search" data-active=search_open.to_string() title="搜索" aria-label="搜索" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "search",
value: None,
compat_path: None,
source: None,
})
}><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span></button>
{if search_open {
view! {
<input
data-testid="mindmap-schema-navigator-search"
data-mindmap-action-id="search"
placeholder="搜索"
aria-label="搜索"
on:input={
let action_target = action_target.clone();
move |event| {
let value = event_target_value(&event);
let value = json!(value);
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "search",
value: Some(&value),
compat_path: None,
source: None,
})
}
}
on:keydown={
let action_target = action_target.clone();
move |event: ev::KeyboardEvent| {
if event.key() == "Escape" {
let value = json!(false);
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "search",
value: Some(&value),
compat_path: None,
source: None,
});
}
}
}
/>
}.into_any()
} else {
().into_any()
}}
</div>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-minimap" data-active=minimap_open.to_string() title="小地图" aria-label="小地图" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_MINIMAP_EVENT, &MindmapShellMinimapPayload { open: !minimap_open })
}><span class="material-symbols-outlined" data-icon="account_tree" aria-hidden="true"></span></button>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-settings" data-active=(sidebar_panel_open && sidebar_trigger_visible).to_string() title="设置" aria-label="设置" on:click={
let action_target = action_target.clone();
let active_panel_id = active_panel_id.clone();
move |_| {
let event = if sidebar_trigger_visible { "togglePanel" } else { "restoreTrigger" };
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_PANEL_EVENT, &MindmapShellPanelPayload {
event,
panel_id: active_panel_id.as_deref(),
})
}
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">""</span></button>
</div>
<div class="mnote-mindmap-schema-navigator-group">
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-zoomOut" data-mindmap-action-id="zoomOut" title="缩小" aria-label="缩小" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "zoomOut",
value: None,
compat_path: None,
source: None,
})
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">""</span></button>
<input
class="mnote-mindmap-schema-navigator-zoom"
data-testid="mindmap-schema-navigator-zoom-input"
value=format!("{}%", navigator.zoom_percent)
aria-label="缩放百分比"
on:change={
let action_target = action_target.clone();
move |event| {
let raw = event_target_value(&event);
let normalized = raw.replace('%', "").trim().to_string();
if let Ok(percent) = normalized.parse::<u32>() {
dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ZOOM_EVENT, &MindmapShellZoomPayload { percent });
} else if let Some(target) = event.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
input.set_value(&format!("{}%", navigator.zoom_percent));
}
}
}
}
on:keydown=move |event: ev::KeyboardEvent| {
if event.key() == "Escape" {
if let Some(target) = event.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
input.set_value(&format!("{}%", navigator.zoom_percent));
input.blur().ok();
}
}
}
}
/>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-zoomIn" data-mindmap-action-id="zoomIn" title="放大" aria-label="放大" on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "zoomIn",
value: None,
compat_path: None,
source: None,
})
}><span class="material-symbols-outlined" data-icon="add" aria-hidden="true"></span></button>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-fullscreenCanvas" data-mindmap-action-id="fullscreenCanvas" data-active=shell.fullscreen.is_fullscreen.to_string() data-disabled-reason=if shell.fullscreen.api_available { "" } else { "fullscreen-unavailable" } disabled=!shell.fullscreen.api_available title=if shell.fullscreen.is_fullscreen { "退出全屏" } else { "全屏查看" } aria-label=if shell.fullscreen.is_fullscreen { "退出全屏" } else { "全屏查看" } on:click={
let action_target = action_target.clone();
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: if shell.fullscreen.is_fullscreen { "exitFullscreen" } else { "fullscreenCanvas" },
value: None,
compat_path: None,
source: None,
})
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">{if shell.fullscreen.is_fullscreen { "" } else { "" }}</span></button>
<button type="button" class="mnote-mindmap-schema-navigator-button" data-testid="mindmap-schema-navigator-action-readonly" data-mindmap-action-id="readonly" data-active=navigator.readonly.to_string() title=if navigator.readonly { "切回编辑" } else { "切换只读" } aria-label=if navigator.readonly { "切回编辑" } else { "切换只读" } on:click={
let action_target = action_target;
move |_| dispatch_mindmap_shell_event(&action_target, MINDMAP_SHELL_ACTION_EVENT, &MindmapShellActionPayload {
action_id: "readonly",
value: None,
compat_path: None,
source: None,
})
}><span class="mnote-mindmap-tool-icon" aria-hidden="true">{if navigator.readonly { "" } else { "" }}</span></button>
</div>
</div> }.into_any()
} else {
().into_any()
}}
{if minimap_open {
view! {
<div
class="mnote-mindmap-minimap"
data-testid="mindmap-schema-minimap"
data-ui-source="leptos-rust-shell"
style=minimap_right_style
>
<div class="mnote-mindmap-minimap-map">
<span></span>
<span></span>
<span></span>
</div>
<div class="mnote-mindmap-minimap-viewport" data-testid="mindmap-schema-minimap-viewport"></div>
</div>
}.into_any()
} else {
().into_any()
}}
</div>
}
}
fn take_mindmap_shell_handle(id: u32) -> Option<MountedMindmapShell> {
MOUNTED_MINDMAP_SHELLS.with(|registry| registry.borrow_mut().remove(&id))
}
#[wasm_bindgen]
pub fn mount_mindmap_shell(container: Element, options: JsValue) -> Result<u32, JsValue> {
console_error_panic_hook::set_once();
let options = serde_wasm_bindgen::from_value::<MindmapShellOptions>(options)?;
let target = container
.dyn_into::<HtmlElement>()
.map_err(|_| JsValue::from_str("mindmap shell mount 目标必须是 HTML 元素"))?;
let mount_id = next_mount_id();
let event_target: EventTarget = target.clone().into();
let handle = mount_to(target, move || {
view! {
<MindmapShell options=options event_target=event_target />
}
});
MOUNTED_MINDMAP_SHELLS.with(|registry| {
registry.borrow_mut().insert(
mount_id,
MountedMindmapShell {
mount_handle: Box::new(handle),
},
);
});
Ok(mount_id)
}
#[wasm_bindgen]
pub fn unmount_mindmap_shell(mount_id: u32) -> Result<(), JsValue> {
if take_mindmap_shell_handle(mount_id).is_some() {
Ok(())
} else {
Err(JsValue::from_str("找不到对应的 mindmap shell 挂载句柄"))
}
}
#[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;
}))
}
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 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_mindmap_object_identity(
object: Option<&RuntimeStandaloneObject>,
) -> Option<PersistedDocumentIdentity> {
let object = object?;
if object.kind.as_deref() != Some("mindmap") {
return None;
}
let document_id = normalize_identity_value(object.document_id.clone())?;
let mindmap_id = normalize_identity_value(object.mindmap_id.clone())?;
Some(persisted_document_identity(
Some(format!("mindmap-object:{document_id}:{mindmap_id}")),
None,
))
}
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 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")
}
pub(crate) fn editor_root_element() -> Option<Element> {
window()
.and_then(|win| win.document())
.and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten())
}
pub(crate) 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 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 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)?;
let index = direct_block_index_from_element(&element, &root, HANDLE_SHELL_SELECTOR)?;
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 table_selection_overlay_style(
anchor: &TableOverlayAnchor,
selection: &TableSelectionOverlayState,
) -> String {
match selection.kind {
TableSelectionKind::Row => {
let row_height = anchor.height / anchor.rows.max(1) as f64;
format!(
"left:0;top:{:.1}px;width:{:.1}px;height:{:.1}px;",
row_height * selection.index as f64,
anchor.width,
row_height
)
}
TableSelectionKind::Column => {
let col_width = anchor.width / anchor.cols.max(1) as f64;
format!(
"left:{:.1}px;top:0;width:{:.1}px;height:{:.1}px;",
col_width * selection.index as f64,
col_width,
anchor.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(match node.get("type").and_then(Value::as_str) {
Some("paragraph") | Some("heading") | Some("blockquote")
| Some("codeBlock") | Some("bulletList") | Some("orderedList")
| Some("taskList") | Some("listItem") | Some("taskItem") | Some("table")
| Some("tableRow") | Some("tableCell") | Some("tableHeader") => 2,
_ => 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 slash_menu_anchor_style() -> String {
const MENU_WIDTH: f64 = 316.0;
const MENU_HEIGHT: f64 = 430.0;
const GAP: f64 = 8.0;
let viewport_width = window()
.and_then(|win| win.inner_width().ok())
.and_then(|value| value.as_f64())
.unwrap_or(1024.0)
.max(320.0);
let viewport_height = window()
.and_then(|win| win.inner_height().ok())
.and_then(|value| value.as_f64())
.unwrap_or(768.0)
.max(240.0);
let selection_rect = window()
.and_then(|win| win.get_selection().ok().flatten())
.and_then(|selection| selection.get_range_at(0).ok())
.map(|range| range.get_bounding_client_rect())
.filter(|rect| rect.top() > 0.0 || rect.left() > 0.0 || rect.height() > 0.0);
let (raw_top, raw_left, raw_anchor_top) = if let Some(rect) = selection_rect {
(rect.bottom() + GAP, rect.left(), rect.top())
} else if let Some(block) = hovered_block_from_selection() {
if let Some(stage) = editor_stage_element() {
let stage_rect = stage.get_bounding_client_rect();
(
stage_rect.top() + block.top + block.height + GAP,
stage_rect.left() + content_text_left(stage_rect.width()),
stage_rect.top() + block.top,
)
} else {
(24.0, 24.0, 24.0)
}
} else {
(24.0, 24.0, 24.0)
};
let clamped_left = raw_left.clamp(GAP, (viewport_width - MENU_WIDTH - GAP).max(GAP));
let below_limit = viewport_height - GAP;
let clamped_top = if raw_top + MENU_HEIGHT > below_limit {
(raw_anchor_top - MENU_HEIGHT - GAP).clamp(GAP, below_limit - 120.0)
} else {
raw_top.clamp(GAP, below_limit - 120.0)
};
format!("top:{clamped_top:.1}px;left:{clamped_left:.1}px;")
}
fn image_toolbar_anchor_from_image(image: &Element) -> Option<ImageToolbarAnchor> {
let stage = editor_stage_element()?;
let rect = image.get_bounding_client_rect();
if rect.width() <= 0.0 && rect.height() <= 0.0 {
return None;
}
let stage_rect = stage.get_bounding_client_rect();
let align = image
.get_attribute("data-align")
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "left".to_string());
Some(ImageToolbarAnchor {
top: (rect.top() - stage_rect.top()).max(60.0),
left: rect.left() + (rect.width() / 2.0) - stage_rect.left(),
align,
})
}
fn image_element_from_target(target: web_sys::EventTarget) -> Option<Element> {
let element = target_element(target)?;
if element.tag_name().eq_ignore_ascii_case("img")
&& element
.closest(".editor-surface .ProseMirror")
.ok()
.flatten()
.is_some()
{
return Some(element);
}
element
.closest(".editor-surface .ProseMirror img[src]")
.ok()
.flatten()
}
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 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);
pointer_in_handle_corridor_geometry(
client_x,
client_y,
&HandleCorridorGeometry {
stage_left: stage_rect.left(),
stage_top: stage_rect.top(),
block_left,
block_top,
block_bottom,
handle_left,
handle_right,
},
)
}
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()?;
Some(drop_indicator_from_block_rects(
hovered.index,
&block,
&stage,
client_y,
))
}
fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option<DropIndicatorState> {
let target = event_target_from_point(client_x, client_y)?;
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 next_mindmap_id() -> String {
let now = js_sys::Date::new_0();
format!(
"思维导图{:02}{:02}{:02}.json",
now.get_hours(),
now.get_minutes(),
now.get_seconds()
)
}
fn apply_html_update(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
next_html: String,
document_id: ReadSignal<Option<String>>,
workspace_id: ReadSignal<Option<String>>,
dirty_count: ReadSignal<u32>,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
hovered_block: ReadSignal<Option<HoveredBlockState>>,
editor_focused: ReadSignal<bool>,
slash_open: ReadSignal<bool>,
turn_into_open: ReadSignal<bool>,
color_menu_open: ReadSignal<bool>,
more_menu_open: ReadSignal<bool>,
revision: ReadSignal<Option<i64>>,
conflict_detection_key: ReadSignal<Option<String>>,
read_only: ReadSignal<bool>,
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);
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(
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 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 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 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::Image,
TiptapExtension::Table,
TiptapExtension::TableRow,
TiptapExtension::TableCell,
TiptapExtension::TableHeader,
TiptapExtension::TocNode,
TiptapExtension::History,
TiptapExtension::Underline,
TiptapExtension::TextStyle,
TiptapExtension::TextAlign,
TiptapExtension::Highlight,
TiptapExtension::Link,
TiptapExtension::Placeholder,
]
}
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 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 dispatch_runtime_state_to_target(
target: &EventTarget,
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_to_target(target, &state);
dispatch_status_event_to_target(
target,
&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_to_target(
target: &EventTarget,
selection: &TiptapSelectionState,
editor_focused: bool,
current_block_index: Option<usize>,
) {
dispatch_selection_event_to_target(
target,
&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 viewport_scroll = current_viewport_scroll();
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());
if let Some((x, y)) = viewport_scroll {
schedule_restore_viewport_scroll(x, y, 10);
}
}
Err(err) => {
set_command_feedback.set(format!("宿主文档同步失败:{err}"));
}
}
}
}
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 apply_image_align(
editor: TiptapEditorHandle,
align: &'static str,
) -> Result<&'static str, String> {
let _ = editor.focus();
let mut attrs = leptos_tiptap::TiptapAttributes::new();
attrs.insert("data-align", align);
editor
.update_attributes(TiptapSchemaTarget::Node(TiptapNodeName::Image), attrs)
.map(|_| "已更新图片对齐")
.map_err(|err| format!("命令执行失败:{err}"))
}
#[derive(Clone, Copy)]
enum TableToolbarAction {
AddRowAfter,
AddColumnAfter,
DeleteRow,
DeleteColumn,
ClearCell,
DeleteTable,
}
#[derive(Clone, Copy)]
enum TableOptionAction {
ToggleHeaderRow,
ToggleHeaderColumn,
ToggleHiddenBorders,
}
impl TableToolbarAction {
fn id(self) -> &'static str {
match self {
Self::AddRowAfter => "add-row-after",
Self::AddColumnAfter => "add-column-after",
Self::DeleteRow => "delete-row",
Self::DeleteColumn => "delete-column",
Self::ClearCell => "clear-cell",
Self::DeleteTable => "delete-table",
}
}
fn icon(self) -> &'static str {
match self {
Self::AddRowAfter => "+R",
Self::AddColumnAfter => "+C",
Self::DeleteRow => "-R",
Self::DeleteColumn => "-C",
Self::ClearCell => "",
Self::DeleteTable => "×",
}
}
fn title(self) -> &'static str {
match self {
Self::AddRowAfter => "下方插入行",
Self::AddColumnAfter => "右侧插入列",
Self::DeleteRow => "删除当前行",
Self::DeleteColumn => "删除当前列",
Self::ClearCell => "清空当前单元格",
Self::DeleteTable => "删除表格",
}
}
}
const TABLE_TOOLBAR_ACTIONS: [TableToolbarAction; 6] = [
TableToolbarAction::AddRowAfter,
TableToolbarAction::AddColumnAfter,
TableToolbarAction::ClearCell,
TableToolbarAction::DeleteRow,
TableToolbarAction::DeleteColumn,
TableToolbarAction::DeleteTable,
];
impl TableOptionAction {
fn id(self) -> &'static str {
match self {
Self::ToggleHeaderRow => "toggle-header-row",
Self::ToggleHeaderColumn => "toggle-header-column",
Self::ToggleHiddenBorders => "toggle-hidden-borders",
}
}
fn title(self) -> &'static str {
match self {
Self::ToggleHeaderRow => "标题行",
Self::ToggleHeaderColumn => "标题列",
Self::ToggleHiddenBorders => "隐藏边框线",
}
}
}
const TABLE_OPTION_ACTIONS: [TableOptionAction; 3] = [
TableOptionAction::ToggleHeaderRow,
TableOptionAction::ToggleHeaderColumn,
TableOptionAction::ToggleHiddenBorders,
];
fn run_table_toolbar_action(
editor: TiptapEditorHandle,
action: TableToolbarAction,
) -> Result<&'static str, String> {
let result = match action {
TableToolbarAction::AddRowAfter => editor.add_table_row_after(),
TableToolbarAction::AddColumnAfter => editor.add_table_column_after(),
TableToolbarAction::DeleteRow => editor.delete_table_row(),
TableToolbarAction::DeleteColumn => editor.delete_table_column(),
TableToolbarAction::ClearCell => editor.clear_table_cell(),
TableToolbarAction::DeleteTable => editor.delete_table(),
};
result
.map(|_| action.title())
.map_err(|err| format!("命令执行失败:{err}"))
}
fn run_table_option_action(
editor: TiptapEditorHandle,
action: TableOptionAction,
) -> Result<&'static str, String> {
let result = match action {
TableOptionAction::ToggleHeaderRow => editor.toggle_table_header_row(),
TableOptionAction::ToggleHeaderColumn => editor.toggle_table_header_column(),
TableOptionAction::ToggleHiddenBorders => editor.toggle_table_hidden_borders(),
};
result
.map(|_| action.title())
.map_err(|err| format!("命令执行失败:{err}"))
}
fn first_table_node(value: &Value) -> Option<&Value> {
if value.get("type").and_then(Value::as_str) == Some("table") {
return Some(value);
}
if let Some(table) = value
.get("props")
.and_then(|props| props.get("tiptapTable"))
.and_then(first_table_node)
{
return Some(table);
}
for key in ["content", "children"] {
if let Some(children) = value.get(key).and_then(Value::as_array) {
if let Some(table) = children.iter().find_map(first_table_node) {
return Some(table);
}
}
}
None
}
fn table_option_checked(document: &Value, action: TableOptionAction) -> bool {
let Some(table) = first_table_node(document) else {
return false;
};
match action {
TableOptionAction::ToggleHiddenBorders => table
.get("attrs")
.and_then(|attrs| attrs.get("hiddenBorders"))
.and_then(Value::as_bool)
.unwrap_or(false),
TableOptionAction::ToggleHeaderRow => table
.get("content")
.and_then(Value::as_array)
.and_then(|rows| rows.first())
.and_then(|row| row.get("content"))
.and_then(Value::as_array)
.map(|cells| {
!cells.is_empty()
&& cells
.iter()
.all(|cell| cell.get("type").and_then(Value::as_str) == Some("tableHeader"))
})
.unwrap_or(false),
TableOptionAction::ToggleHeaderColumn => table
.get("content")
.and_then(Value::as_array)
.map(|rows| {
!rows.is_empty()
&& rows.iter().all(|row| {
row.get("content")
.and_then(Value::as_array)
.and_then(|cells| cells.first())
.and_then(|cell| cell.get("type"))
.and_then(Value::as_str)
== Some("tableHeader")
})
})
.unwrap_or(false),
}
}
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(),
SlashActionKind::SimpleTable => {
let _ = editor.focus();
editor.insert_table(4, 3, false)
}
SlashActionKind::Mindmap => {
editor.insert_content(
TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)),
None,
)
}
SlashActionKind::Image => {
dispatch_editor_upload_request("image", "image/*")?;
return Ok("已打开图片上传");
}
SlashActionKind::UploadAttachment => {
dispatch_editor_upload_request("attachment", "")?;
return Ok("已打开附件上传");
}
SlashActionKind::Toc => editor.insert_toc_node(TiptapTocNodeAttrs {
top_offset: Some(0),
max_show_count: Some(20),
show_title: Some(true),
}),
};
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 => "已插入分割线",
SlashActionKind::SimpleTable => "已插入简单表格",
SlashActionKind::Mindmap => "已插入思维导图",
SlashActionKind::Image => "已打开图片上传",
SlashActionKind::UploadAttachment => "已打开附件上传",
SlashActionKind::Toc => "已插入页面目录",
})
.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::SimpleTable => {
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.insert_table(4, 3, false)
.map(|_| "已把当前块转成简单表格")
.map_err(|err| format!("命令执行失败:{err}"))
}
SlashActionKind::Toc => {
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.insert_toc_node(TiptapTocNodeAttrs {
top_offset: Some(0),
max_show_count: Some(20),
show_title: Some(true),
})
.map(|_| "已把当前块转成页面目录")
.map_err(|err| format!("命令执行失败:{err}"))
}
SlashActionKind::Divider => editor
.insert_content_at(
range,
TiptapContent::json(json!({ "type": "horizontalRule" })),
None,
)
.map(|_| "已把当前块转成分割线")
.map_err(|err| format!("命令执行失败:{err}")),
SlashActionKind::Mindmap => editor
.insert_content_at(
range,
TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)),
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 heading_level_from_u8(level: u8) -> TiptapHeadingLevel {
match level {
1 => TiptapHeadingLevel::H1,
2 => TiptapHeadingLevel::H2,
3 => TiptapHeadingLevel::H3,
4 => TiptapHeadingLevel::H4,
5 => TiptapHeadingLevel::H5,
_ => TiptapHeadingLevel::H6,
}
}
fn run_block_turn_into_folded_heading_action(
editor: TiptapEditorHandle,
block_index: usize,
level: u8,
) -> 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))?;
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
editor
.clear_nodes()
.map_err(|err| format!("清理当前块样式失败:{err}"))?;
editor
.set_heading_with_collapsed(heading_level_from_u8(level), true)
.map_err(|err| format!("命令执行失败:{err}"))?;
let mut attrs = leptos_tiptap::TiptapAttributes::new();
attrs.insert("collapsed", true);
editor
.update_attributes(TiptapSchemaTarget::Node(TiptapNodeName::Heading), attrs)
.map_err(|err| format!("写入折叠属性失败:{err}"))?;
let _ = editor.focus();
let _ = editor.select_textblock_end();
Ok(match level {
1 => "已切到折叠主标题",
2 => "已切到折叠大标题",
3 => "已切到折叠中标题",
4 => "已切到折叠小标题",
_ => "已切到折叠标题",
})
}
fn normalized_page_block_title(text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
"未命名页面".to_string()
} else {
trimmed.to_string()
}
}
#[derive(Debug, Deserialize)]
struct TreeCommandHttpResponse {
result: Option<TreeCommandResult>,
message: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TreeCommandResult {
document_id: Option<String>,
workspace_id: Option<String>,
title: Option<String>,
parent_id: Option<String>,
}
fn dispatch_tree_local_command(body: &Value, result: &Value) {
let Some(win) = window() else {
return;
};
let Ok(detail) = serde_wasm_bindgen::to_value(&json!({
"body": body,
"result": result,
})) else {
return;
};
let init = CustomEventInit::new();
init.set_detail(&detail);
if let Ok(event) = CustomEvent::new_with_event_init_dict("tree:local-command", &init) {
let _ = win.dispatch_event(&event);
}
}
async fn create_child_page_via_tree_command(
parent_id: String,
workspace_id: Option<String>,
title: String,
) -> Result<TreeCommandResult, String> {
let body = json!({
"action": "create",
"workspaceId": workspace_id,
"parentId": parent_id,
"title": title,
"accessScope": "private",
"content": [],
});
let body_string =
serde_json::to_string(&body).map_err(|err| format!("序列化创建子页面请求失败:{err}"))?;
let request_init = RequestInit::new();
request_init.set_method("POST");
request_init.set_mode(RequestMode::SameOrigin);
request_init.set_body(&JsValue::from_str(&body_string));
let headers = js_sys::Object::new();
js_sys::Reflect::set(
&headers,
&JsValue::from_str("content-type"),
&JsValue::from_str("application/json"),
)
.map_err(|_| "设置创建子页面请求头失败".to_string())?;
js_sys::Reflect::set(
request_init.as_ref(),
&JsValue::from_str("headers"),
&headers,
)
.map_err(|_| "设置创建子页面请求头失败".to_string())?;
let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?;
let response_value =
JsFuture::from(win.fetch_with_str_and_init("/api/tree/commands", &request_init))
.await
.map_err(|err| format!("创建子页面请求失败:{err:?}"))?;
let response: Response = response_value
.dyn_into()
.map_err(|_| "创建子页面响应类型错误".to_string())?;
let status = response.status();
let json_value = JsFuture::from(
response
.json()
.map_err(|_| "读取创建子页面响应失败".to_string())?,
)
.await
.map_err(|err| format!("解析创建子页面响应失败:{err:?}"))?;
let parsed: TreeCommandHttpResponse = serde_wasm_bindgen::from_value(json_value.clone())
.map_err(|err| format!("创建子页面响应 JSON 不符合预期:{err}"))?;
if !response.ok() {
return Err(parsed
.message
.unwrap_or_else(|| format!("创建子页面失败:HTTP {status}")));
}
let result = parsed
.result
.ok_or_else(|| "创建子页面响应缺少 result".to_string())?;
let document_id = result
.document_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "创建子页面响应缺少 documentId".to_string())?;
if document_id == parent_id {
return Err("创建子页面返回了当前页面 id".to_string());
}
if let Some(created_parent_id) = result
.parent_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if created_parent_id != parent_id {
return Err(format!(
"创建子页面父节点不符合预期:期望 {parent_id},实际 {created_parent_id}"
));
}
}
dispatch_tree_local_command(
&body,
&serde_wasm_bindgen::from_value(json_value).unwrap_or(Value::Null)["result"],
);
Ok(result)
}
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 top_level_block_matches_action(
editor: TiptapEditorHandle,
block_index: usize,
action: SlashActionKind,
) -> bool {
let Some(block) = editor.get_json().ok().and_then(|document| {
document
.get("content")
.and_then(Value::as_array)
.and_then(|content| content.get(block_index).cloned())
}) else {
return false;
};
let node_type = block
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match action {
SlashActionKind::Paragraph => {
node_type == "paragraph" && !inline_has_page_block_link(&block)
}
SlashActionKind::Heading1
| SlashActionKind::Heading2
| SlashActionKind::Heading3
| SlashActionKind::Heading4 => {
let expected_level = match action {
SlashActionKind::Heading1 => 1,
SlashActionKind::Heading2 => 2,
SlashActionKind::Heading3 => 3,
SlashActionKind::Heading4 => 4,
_ => 0,
};
node_type == "heading"
&& block
.get("attrs")
.and_then(|attrs| attrs.get("level"))
.and_then(Value::as_i64)
.map(|level| level == expected_level)
.unwrap_or(false)
}
SlashActionKind::BulletList => node_type == "bulletList",
SlashActionKind::OrderedList => node_type == "orderedList",
SlashActionKind::Todo | SlashActionKind::AdvancedTodo => node_type == "taskList",
SlashActionKind::Quote => node_type == "blockquote",
SlashActionKind::CodeBlock => node_type == "codeBlock",
SlashActionKind::Divider => node_type == "horizontalRule",
SlashActionKind::SimpleTable => node_type == "table",
SlashActionKind::Mindmap => {
node_type == "paragraph"
&& block
.get("attrs")
.and_then(|attrs| attrs.get("mnoteBlockType"))
.and_then(Value::as_str)
== Some("mindmap")
}
SlashActionKind::Image => node_type == "image",
SlashActionKind::UploadAttachment => false,
SlashActionKind::Toc => node_type == "tocNode",
SlashActionKind::AiAssistant
| SlashActionKind::AiWrite
| SlashActionKind::ContinueWriting
| SlashActionKind::Summarize
| SlashActionKind::MoreAi => false,
}
}
fn page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, title: &str) -> Value {
let href = if page_id.trim().is_empty() {
"#".to_string()
} else if let Some(workspace_id) = workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("/documents/{page_id}?workspaceId={workspace_id}")
} else {
format!("/documents/{page_id}")
};
paragraph_node(vec![json!({
"type": "text",
"text": normalized_page_block_title(title),
"marks": [{
"type": "link",
"attrs": {
"href": href,
"target": "_self",
"rel": "noopener noreferrer nofollow",
"class": "mnote-page-block-link",
}
}]
})])
}
fn replace_block_with_page_reference(
editor: TiptapEditorHandle,
block_index: usize,
page_id: &str,
workspace_id: Option<&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, workspace_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 Some(parent_document_id) = document_id.get_untracked() else {
set_command_feedback.set("转换为页面失败:当前页面缺少 documentId".to_string());
return;
};
let workspace_id_value = workspace_id.get_untracked();
set_command_feedback.set(format!("正在创建子页面:{page_title}"));
spawn_local(async move {
match create_child_page_via_tree_command(
parent_document_id.clone(),
workspace_id_value.clone(),
page_title.clone(),
)
.await
{
Ok(created) => {
let Some(child_page_id) = created.document_id.as_deref() else {
set_command_feedback.set("转换为页面失败:创建响应缺少子页面 id".to_string());
return;
};
let effective_workspace_id = created
.workspace_id
.as_deref()
.or(workspace_id_value.as_deref());
let effective_title = created.title.as_deref().unwrap_or(&page_title);
match replace_block_with_page_reference(
editor,
block_index,
child_page_id,
effective_workspace_id,
effective_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!("已转换为子页面:{effective_title}"),
);
}
Err(err) => set_command_feedback.set(err),
}
}
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 open_hermes_page_ai_drawer() {
let Some(win) = window() else {
return;
};
let Some(document) = win.document() else {
return;
};
if let Ok(Some(trigger)) = document.query_selector("[data-mnote-action=\"open-page-ai\"]") {
if let Some(button) = trigger.dyn_ref::<HtmlElement>() {
button.click();
}
}
}
fn request_ai_edit_bridge(
editor: TiptapEditorHandle,
action: &'static str,
document_id: ReadSignal<Option<String>>,
workspace_id: ReadSignal<Option<String>>,
selected_block_index: Option<usize>,
selected_block_id: Option<String>,
selection_state: ReadSignal<TiptapSelectionState>,
set_ai_bridge_state: WriteSignal<String>,
set_ai_bridge_message: WriteSignal<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>,
) {
let _ = (
editor,
action,
document_id,
workspace_id,
selected_block_index,
selected_block_id,
selection_state,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
);
let _ = (
set_ai_bridge_state,
set_ai_bridge_message,
set_command_feedback,
);
open_hermes_page_ai_drawer();
}
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 current_mount_id = runtime_mount_context().map(|(id, _, _)| id);
let editor_instance_id = runtime_editor_instance_id(current_mount_id);
let editor_stage_id = format!("{editor_instance_id}-stage");
let runtime_event_target = runtime_mount_context()
.map(|(_, target, _)| target)
.or_else(|| {
window()
.and_then(|win| win.document())
.map(|document| document.into())
});
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 = persisted_mindmap_object_identity(
mount_options.standalone_object.as_ref(),
)
.unwrap_or_else(|| {
resolve_persisted_document_identity(
initial_document_id.clone(),
initial_workspace_id.clone(),
)
});
let persisted = load_persisted_document(&persisted_identity);
let has_explicit_bootstrap_content =
mount_options.html.is_some() || mount_options.content.is_some();
let restored_from_storage = persisted.is_some() && !has_explicit_bootstrap_content;
let restored_html = if restored_from_storage {
persisted
.as_ref()
.and_then(|document| document.html.clone())
} else {
None
};
let initial_title_value = mount_options
.title
.clone()
.or_else(|| {
restored_from_storage
.then(|| persisted.as_ref().map(|document| document.title.clone()))
.flatten()
})
.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(|| {
if restored_from_storage {
persisted.as_ref().map(|document| {
document
.html
.clone()
.map(TiptapContent::html)
.unwrap_or_else(|| TiptapContent::json(document.content.clone()))
})
} else {
None
}
})
.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(mount_options.content.clone().unwrap_or_else(|| {
if restored_from_storage {
persisted
.as_ref()
.map(|document| document.content.clone())
.unwrap_or_else(|| json!({"type": "doc", "content": []}))
} 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 (table_toolbar_open, set_table_toolbar_open) = signal(false);
let (table_options_open, set_table_options_open) = signal(false);
let (table_overlay_anchor, set_table_overlay_anchor) = signal(None::<TableOverlayAnchor>);
let (table_selection_overlay, set_table_selection_overlay) =
signal(None::<TableSelectionOverlayState>);
let (floating_toolbar_anchor, set_floating_toolbar_anchor) =
signal(None::<FloatingToolbarAnchor>);
let (image_toolbar_anchor, set_image_toolbar_anchor) = signal(None::<ImageToolbarAnchor>);
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 (slash_query, set_slash_query) = signal(String::new());
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 (block_folded_title_open, set_block_folded_title_open) = signal(false);
let (_ai_bridge_state, set_ai_bridge_state) = signal("idle".to_string());
let (_ai_bridge_message, set_ai_bridge_message) = signal("".to_string());
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 command_event_target = runtime_event_target.clone();
let ready_event_target = runtime_event_target.clone();
let change_event_target = runtime_event_target.clone();
let selection_event_target = runtime_event_target.clone();
let slash_change_event_target = runtime_event_target.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;
let set_block_folded_title_open = set_block_folded_title_open;
Effect::new(move |_| {
if !block_menu_open.get() {
set_block_turn_into_open.set(false);
set_block_folded_title_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_event_target = command_event_target.clone();
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);
if let Some(target) = command_event_target.as_ref() {
dispatch_runtime_state_to_target(
target,
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),
);
if let Some(target) = command_event_target.as_ref() {
dispatch_status_event_to_target(
target,
&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);
}
}
// ── Block-delta listenerPhase BAI 写入增量通道) ──
let delta_editor = editor;
let delta_set_document_json = set_document_json;
let delta_json_output = json_output;
let delta_set_json_output = set_json_output;
let delta_set_dirty_count = set_dirty_count;
let delta_set_html_output = set_html_output;
let delta_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(delta): Result<Value, _> =
serde_wasm_bindgen::from_value(detail)
else {
return;
};
let Some(operations) = delta.get("operations").and_then(Value::as_array) else {
return;
};
if operations.is_empty() {
return;
}
// Editor instance maybe unavailable while loading
let Some(instance) = delta_editor.instance_untracked() else {
return;
};
// Read current content
let Ok(mut content) = instance.get_json() else {
return;
};
// Apply delta operations to the Tiptap JSON tree
let changed = apply_block_delta_to_json(&mut content, operations);
if !changed {
return;
}
// Write back
if instance.set_content(TiptapContent::json(content.clone())).is_ok() {
// Update reactive state
let html = instance.get_html().unwrap_or_default();
let json_text = serde_json::to_string(&content).unwrap_or_default();
delta_set_dirty_count.update(|c| *c += 1);
delta_set_html_output.set(html);
delta_set_json_output.set(json_text);
delta_set_document_json.set(content);
}
}));
if let Some(document) = window().and_then(|win| win.document()) {
let delta_ref = delta_listener.as_ref().unchecked_ref();
let _ = document.add_event_listener_with_callback(BLOCK_DELTA_EVENT, delta_ref);
if let Some((mount_id, _, _)) = runtime_mount_context() {
let target: EventTarget = document.into();
register_runtime_listener(mount_id, target, delta_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_table_toolbar_open = set_table_toolbar_open;
let set_table_options_open = set_table_options_open;
let set_table_overlay_anchor = set_table_overlay_anchor;
let set_table_selection_overlay = set_table_selection_overlay;
let set_floating_toolbar_anchor = set_floating_toolbar_anchor;
let set_image_toolbar_anchor = set_image_toolbar_anchor;
let set_slash_open = set_slash_open;
let set_turn_into_open = set_turn_into_open;
let set_hovered_block = set_hovered_block;
let set_block_menu_anchor = set_block_menu_anchor;
let set_block_menu_open = set_block_menu_open;
let set_pending_drag = set_pending_drag;
let set_dragging_block_index = set_dragging_block_index;
let set_dragging_block_anchor = set_dragging_block_anchor;
let set_drop_indicator = set_drop_indicator;
let set_command_feedback = set_command_feedback;
let set_suppress_handle_click = set_suppress_handle_click;
let pending_drag = pending_drag;
let dragging_block_index = dragging_block_index;
let mouseup_handle = window_event_listener(ev::mouseup, move |event: MouseEvent| {
if let Some(source_index) = dragging_block_index.try_get_untracked().flatten() {
let indicator = drop_indicator_from_point(event.client_x(), event.client_y());
if let Some(indicator) = indicator {
match editor.get_html() {
Ok(current_html) => match reorder_top_level_block_html(
&current_html,
source_index,
indicator.index,
indicator.placement,
) {
Ok(next_html) => {
apply_html_update(
editor,
&persisted_identity,
next_html,
document_id,
workspace_id,
dirty_count,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
hovered_block,
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
revision,
conflict_detection_key,
read_only,
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, .image-floating-toolbar, .table-toolbar, .table-controls"))
.ok()
.flatten()
.is_some()
{
return;
}
let in_image = element
.closest(".editor-surface .ProseMirror img[src]")
.ok()
.flatten()
.is_some();
if !in_image {
let _ = set_image_toolbar_anchor.try_set(None);
}
let _ = set_table_options_open.try_set(false);
let table = element
.closest(".editor-surface .ProseMirror table")
.ok()
.flatten();
let in_table = table.is_some();
let _ = set_table_toolbar_open.try_set(in_table);
if let Some(table) = table {
let _ = set_table_overlay_anchor
.try_set(table_overlay_anchor_from_table(&table));
} else {
let _ = set_table_overlay_anchor.try_set(None);
let _ = set_table_selection_overlay.try_set(None);
}
}
}
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 slash_query = slash_query;
let set_slash_query = set_slash_query;
let turn_into_open = turn_into_open;
let set_turn_into_open = set_turn_into_open;
let color_menu_open = color_menu_open;
let set_color_menu_open = set_color_menu_open;
let more_menu_open = more_menu_open;
let set_more_menu_open = set_more_menu_open;
let set_locked_toolbar_anchor = set_locked_toolbar_anchor;
let set_text_selection_active = set_text_selection_active;
let set_floating_toolbar_anchor = set_floating_toolbar_anchor;
let image_toolbar_anchor = image_toolbar_anchor;
let set_image_toolbar_anchor = set_image_toolbar_anchor;
let table_toolbar_open = table_toolbar_open;
let set_table_toolbar_open = set_table_toolbar_open;
let table_options_open = table_options_open;
let set_table_options_open = set_table_options_open;
let set_table_overlay_anchor = set_table_overlay_anchor;
let set_table_selection_overlay = set_table_selection_overlay;
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_block_turn_into_open = set_block_turn_into_open;
let set_block_folded_title_open = set_block_folded_title_open;
let set_command_feedback = set_command_feedback;
let keydown_capture_closure =
SendWrapper::new(Closure::wrap(Box::new(move |event: ev::KeyboardEvent| {
if !active_editor_stage()
|| event.ctrl_key()
|| event.meta_key()
|| event.alt_key()
|| !matches!(event.key().as_str(), "Backspace" | "Delete")
|| !editor_runtime::attachment_links::is_local_attachment_link_selected()
{
return;
}
event.prevent_default();
event.stop_immediate_propagation();
match editor_runtime::history_safe_commands::delete_selected_attachment_link_with_history(
&editor,
) {
Ok(()) => {
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,
"已删除附件引用",
);
let _ = set_hovered_block.try_set(None);
let _ = set_block_menu_open.try_set(false);
let _ = set_block_menu_anchor.try_set(None);
}
Err(err) => {
let _ = set_command_feedback.try_set(format!("删除附件引用失败:{err}"));
}
}
}) as Box<dyn FnMut(_)>));
if let Some(win) = window() {
let _ = win.add_event_listener_with_callback_and_bool(
"keydown",
keydown_capture_closure.as_ref().unchecked_ref(),
true,
);
}
on_cleanup(move || {
if let Some(win) = window() {
let _ = win.remove_event_listener_with_callback_and_bool(
"keydown",
keydown_capture_closure.as_ref().unchecked_ref(),
true,
);
}
drop(keydown_capture_closure);
});
let keydown_handle = window_event_listener(ev::keydown, move |event| {
let focused = active_editor_stage();
if !focused {
if body_has_focus() && (event.ctrl_key() || event.meta_key()) && !event.alt_key() {
let key = event.key();
let undo_shortcut = !event.shift_key() && key.eq_ignore_ascii_case("z");
let redo_shortcut =
key.eq_ignore_ascii_case("y")
|| (event.shift_key() && key.eq_ignore_ascii_case("z"));
if undo_shortcut || redo_shortcut {
event.prevent_default();
if redo_shortcut {
let _ = editor.redo();
} else {
let _ = editor.undo();
}
schedule_editor_focus(editor);
return;
}
}
return;
}
if (event.ctrl_key() || event.meta_key())
&& event.shift_key()
&& event.key().eq_ignore_ascii_case("u")
{
event.prevent_default();
let Some(block) = hovered_block_from_selection()
.or_else(|| hovered_block.try_get_untracked().flatten())
.or_else(|| block_state_from_index(0))
else {
let _ =
set_command_feedback.try_set("没有可进入布局选中态的当前块".to_string());
return;
};
let _ = set_hovered_block.try_set(Some(block.clone()));
let _ = set_block_keyboard_mode.try_set(true);
let _ = set_slash_open.try_set(false);
let _ = set_slash_query.try_set(String::new());
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_command_feedback.try_set(format!("已进入块布局选中态:{}", block.label));
return;
}
if (event.ctrl_key() || event.meta_key()) && event.shift_key() && !event.alt_key() {
let action = match event.key().as_str() {
"1" | "!" => Some(SlashActionKind::Heading1),
"2" | "@" => Some(SlashActionKind::Heading2),
"3" | "#" => Some(SlashActionKind::Heading3),
"5" | "%" => Some(SlashActionKind::Todo),
"6" | "^" => Some(SlashActionKind::BulletList),
"7" | "&" => Some(SlashActionKind::OrderedList),
_ => None,
};
if let Some(action) = action {
event.prevent_default();
let result = run_slash_action(editor, action);
let message = match result {
Ok(message) => {
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,
message,
);
None
}
Err(err) => Some(err),
};
if let Some(message) = message {
let _ = set_command_feedback.try_set(message);
}
let _ = set_slash_open.try_set(false);
let _ = set_slash_query.try_set(String::new());
let _ = set_turn_into_open.try_set(false);
let _ = set_block_menu_open.try_set(false);
let _ = set_block_menu_anchor.try_set(None);
return;
}
}
if event.key() == "Escape" {
event.prevent_default();
if close_editor_floating_overlays_if_escape(
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
image_toolbar_anchor,
table_toolbar_open,
table_options_open,
block_menu_open,
set_slash_open,
set_slash_index,
set_slash_query,
set_turn_into_open,
set_color_menu_open,
set_more_menu_open,
set_locked_toolbar_anchor,
set_text_selection_active,
set_floating_toolbar_anchor,
set_image_toolbar_anchor,
set_table_toolbar_open,
set_table_options_open,
set_table_overlay_anchor,
set_table_selection_overlay,
set_block_menu_open,
set_block_menu_anchor,
set_block_turn_into_open,
set_block_folded_title_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();
open_slash_menu_overlay(
set_slash_open,
set_slash_index,
set_slash_query,
set_turn_into_open,
set_color_menu_open,
set_more_menu_open,
set_locked_toolbar_anchor,
set_text_selection_active,
set_floating_toolbar_anchor,
set_image_toolbar_anchor,
set_table_toolbar_open,
set_table_options_open,
set_table_overlay_anchor,
set_table_selection_overlay,
set_block_menu_open,
set_block_menu_anchor,
set_block_turn_into_open,
set_block_folded_title_open,
);
let _ = set_block_keyboard_mode.try_set(false);
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);
}
"Backspace" => {
if !slash_query
.try_get_untracked()
.unwrap_or_default()
.is_empty()
{
event.prevent_default();
let _ = set_slash_query.try_update(|query| {
query.pop();
});
let _ = set_slash_index.try_set(0);
}
}
"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);
}
_ => {
let key = event.key();
if key.chars().count() == 1
&& !event.ctrl_key()
&& !event.meta_key()
&& !event.alt_key()
{
event.prevent_default();
let _ = set_slash_query.try_update(|query| query.push_str(&key));
let _ = set_slash_index.try_set(0);
}
}
}
});
on_cleanup(move || drop(keydown_handle));
}
{
let click_handle = window_event_listener(ev::click, move |event: MouseEvent| {
let Some(element) = event.target().and_then(target_element) else {
return;
};
let Some(anchor) = element.closest("a.mnote-page-block-link").ok().flatten() else {
return;
};
let Some(href) = anchor
.get_attribute("href")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty() && value != "#")
else {
return;
};
event.prevent_default();
event.stop_propagation();
if let Some(win) = window() {
let _ = win.location().assign(&href);
}
});
on_cleanup(move || drop(click_handle));
}
{
schedule_scroll_mnote_block_anchor_from_hash();
let hashchange_handle = window_event_listener(ev::hashchange, move |_| {
schedule_scroll_mnote_block_anchor_from_hash();
});
on_cleanup(move || drop(hashchange_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_id.clone()
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_within_handle_shell(&element, HANDLE_SHELL_SELECTOR) {
return;
}
}
if let Some(image) = image_element_from_target(target.clone()) {
let _ = select_mnote_image_node(&image);
set_editor_focused.set(true);
open_image_toolbar_overlay(
image_toolbar_anchor_from_image(&image),
set_block_keyboard_mode,
set_hovered_block,
set_slash_open,
set_slash_index,
set_slash_query,
set_turn_into_open,
set_color_menu_open,
set_more_menu_open,
set_locked_toolbar_anchor,
set_text_selection_active,
set_floating_toolbar_anchor,
set_image_toolbar_anchor,
set_table_toolbar_open,
set_table_options_open,
set_table_overlay_anchor,
set_table_selection_overlay,
set_block_menu_open,
set_block_menu_anchor,
set_block_turn_into_open,
set_block_folded_title_open,
);
return;
}
set_image_toolbar_anchor.set(None);
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_within_handle_shell(&element, HANDLE_SHELL_SELECTOR) {
return;
}
}
set_editor_focused.set(true);
if let Some(block) = hovered_block_from_target(target) {
set_hovered_block.set(Some(block));
return;
}
if let Some(current_block) = hovered_block.get_untracked() {
if pointer_in_handle_corridor(event.client_x(), event.client_y(), &current_block) {
return;
}
}
set_hovered_block.set(None);
}
on:mouseover=move |event: MouseEvent| {
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
return;
}
let Some(target) = event.target() else {
return;
};
if let Some(block) = hovered_block_from_target(target) {
set_editor_focused.set(true);
set_hovered_block.set(Some(block));
}
}
on:mouseleave=move |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(), &current_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_within_handle_shell(&element, HANDLE_SHELL_SELECTOR) {
return;
}
}
set_drop_indicator.set(drop_indicator_from_target(target, event.client_y()));
}
on:drop=move |event: DragEvent| {
event.prevent_default();
let Some(source_index) = dragging_block_index.get_untracked() else {
return;
};
let Some(target) = event.target() else {
set_dragging_block_index.set(None);
set_dragging_block_anchor.set(None);
set_drop_indicator.set(None);
return;
};
let Some(indicator) = drop_indicator_from_target(target, event.client_y()) else {
set_dragging_block_index.set(None);
set_dragging_block_anchor.set(None);
set_drop_indicator.set(None);
return;
};
match editor.get_html() {
Ok(current_html) => match reorder_top_level_block_html(
&current_html,
source_index,
indicator.index,
indicator.placement,
) {
Ok(next_html) => {
apply_html_update(
editor,
&runtime_persisted_identity(document_id, workspace_id),
next_html,
document_id,
workspace_id,
dirty_count,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
hovered_block,
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
revision,
conflict_detection_key,
read_only,
set_command_feedback,
"已通过块手柄拖拽重排",
);
}
Err(err) => {
set_command_feedback.set(err);
}
},
Err(err) => {
set_command_feedback.set(format!("读取当前 HTML 失败:{err}"));
}
}
set_dragging_block_index.set(None);
set_dragging_block_anchor.set(None);
set_drop_indicator.set(None);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_hovered_block.set(None);
}
>
{move || {
if !table_toolbar_open.get() || slash_open.get() || block_menu_open.get() || dragging_block_index.get().is_some() {
return ().into_any();
}
let Some(anchor) = table_overlay_anchor.get() else {
return ().into_any();
};
let controls_style = format!(
"left:{:.1}px;top:{:.1}px;width:{:.1}px;height:{:.1}px;",
anchor.left,
anchor.top,
anchor.width,
anchor.height
);
view! {
<div
class="table-controls"
data-testid="mnote-leptos-tiptap-table-controls"
style=controls_style
>
{move || {
table_selection_overlay.get().map(|selection| {
view! {
<div
class="table-selection-overlay"
data-testid="table-selection-overlay"
data-selection-kind=selection.kind.id()
data-selection-index=selection.index.to_string()
style=table_selection_overlay_style(&anchor, &selection)
></div>
}
}).into_any()
}}
{(0..anchor.rows).map(|index| {
let active = table_selection_overlay.get()
.map(|selection| selection.kind == TableSelectionKind::Row && selection.index == index)
.unwrap_or(false);
view! {
<button
type="button"
class="table-aux-btn table-row-aux"
data-testid=format!("table-row-aux-{index}")
data-active=active.to_string()
style=table_row_aux_style(&anchor, index)
title="点击插入一行"
aria-label="点击插入一行"
on:mousedown=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
set_table_toolbar_open.set(true);
set_table_options_open.set(false);
match run_table_toolbar_action(editor, TableToolbarAction::AddRowAfter) {
Ok(message) => {
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,
message,
);
if let Err(err) = editor.select_table_row(index as u32) {
set_command_feedback.set(format!("行已插入,但表格行选区同步失败:{err}"));
}
set_table_overlay_anchor.set(current_table_overlay_anchor());
set_table_selection_overlay.set(Some(TableSelectionOverlayState { kind: TableSelectionKind::Row, index }));
}
Err(err) => set_command_feedback.set(err),
}
}
>"+"</button>
}
}).collect_view()}
{(0..anchor.cols).map(|index| {
let active = table_selection_overlay.get()
.map(|selection| selection.kind == TableSelectionKind::Column && selection.index == index)
.unwrap_or(false);
view! {
<button
type="button"
class="table-aux-btn table-col-aux"
data-testid=format!("table-col-aux-{index}")
data-active=active.to_string()
style=table_col_aux_style(&anchor, index)
title="点击插入一列"
aria-label="点击插入一列"
on:mousedown=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
set_table_toolbar_open.set(true);
set_table_options_open.set(false);
match run_table_toolbar_action(editor, TableToolbarAction::AddColumnAfter) {
Ok(message) => {
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,
message,
);
if let Err(err) = editor.select_table_column(index as u32) {
set_command_feedback.set(format!("列已插入,但表格列选区同步失败:{err}"));
}
set_table_overlay_anchor.set(current_table_overlay_anchor());
set_table_selection_overlay.set(Some(TableSelectionOverlayState { kind: TableSelectionKind::Column, index }));
}
Err(err) => set_command_feedback.set(err),
}
}
>"+"</button>
}
}).collect_view()}
</div>
}.into_any()
}}
{move || {
if !table_toolbar_open.get() || slash_open.get() || block_menu_open.get() || dragging_block_index.get().is_some() {
return ().into_any();
}
view! {
<div
class="table-toolbar"
data-testid="mnote-leptos-tiptap-table-toolbar"
on:mousedown=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
>
{TABLE_TOOLBAR_ACTIONS.iter().enumerate().map(|(index, action)| {
let action = *action;
let testid = format!("table-toolbar-{}", action.id());
view! {
<>
{if index == 3 {
view! { <span class="table-toolbar-separator" aria-hidden="true"></span> }.into_any()
} else {
().into_any()
}}
<button
type="button"
class="table-toolbar-btn"
data-testid=testid
title=action.title()
aria-label=action.title()
on:click=move |_| {
match run_table_toolbar_action(editor, action) {
Ok(message) => {
if matches!(action, TableToolbarAction::DeleteTable) {
set_table_toolbar_open.set(false);
set_table_options_open.set(false);
set_table_overlay_anchor.set(None);
set_table_selection_overlay.set(None);
} else {
set_table_selection_overlay.set(None);
}
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,
message,
);
if !matches!(action, TableToolbarAction::DeleteTable) {
set_table_overlay_anchor.set(current_table_overlay_anchor());
}
}
Err(err) => set_command_feedback.set(err),
}
}
>
{action.icon()}
</button>
</>
}
}).collect_view()}
<span class="table-toolbar-separator" aria-hidden="true"></span>
<button
type="button"
class="table-toolbar-btn"
data-testid="table-toolbar-options"
title="选项"
aria-label="选项"
aria-expanded=move || table_options_open.get().to_string()
on:click=move |_| {
set_table_options_open.update(|open| *open = !*open);
}
>
"选项"
</button>
{move || {
if !table_options_open.get() {
return ().into_any();
}
view! {
<div class="table-options-menu" data-testid="table-toolbar-options-menu">
{TABLE_OPTION_ACTIONS.iter().map(|action| {
let action = *action;
let testid = format!("table-option-{}", action.id());
view! {
<button
type="button"
class="table-option-btn"
data-testid=testid
role="switch"
aria-checked=move || table_option_checked(&document_json.get(), action).to_string()
on:click=move |_| {
match run_table_option_action(editor, action) {
Ok(message) => {
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,
message,
);
}
Err(err) => set_command_feedback.set(err),
}
}
>
<span>{action.title()}</span>
<span class="table-option-switch" aria-hidden="true"></span>
</button>
}
}).collect_view()}
</div>
}.into_any()
}}
</div>
}.into_any()
}}
{move || {
let Some(anchor) = image_toolbar_anchor.get() else {
return ().into_any();
};
if slash_open.get() || block_menu_open.get() || dragging_block_index.get().is_some() {
return ().into_any();
}
view! {
<div
class="image-floating-toolbar"
data-testid="image-floating-toolbar"
data-align=anchor.align.clone()
style:top=format!("{}px", anchor.top)
style:left=format!("{}px", anchor.left)
on:mousedown=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
>
<button
type="button"
class="image-toolbar-btn"
data-testid="image-align-left"
data-active=move || image_toolbar_anchor.get().map(|anchor| anchor.align == "left").unwrap_or(true).to_string()
title="左对齐"
aria-label="左对齐"
on:click=move |_| {
match apply_image_align(editor, "left") {
Ok(message) => {
set_image_toolbar_anchor.update(|state| {
if let Some(anchor) = state.as_mut() {
anchor.align = "left".to_string();
}
});
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,
message,
);
}
Err(err) => set_command_feedback.set(err),
}
}
>"左"</button>
<button
type="button"
class="image-toolbar-btn"
data-testid="image-align-center"
data-active=move || image_toolbar_anchor.get().map(|anchor| anchor.align == "center").unwrap_or(false).to_string()
title="居中"
aria-label="居中"
on:click=move |_| {
match apply_image_align(editor, "center") {
Ok(message) => {
set_image_toolbar_anchor.update(|state| {
if let Some(anchor) = state.as_mut() {
anchor.align = "center".to_string();
}
});
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,
message,
);
}
Err(err) => set_command_feedback.set(err),
}
}
>"中"</button>
<button
type="button"
class="image-toolbar-btn"
data-testid="image-align-right"
data-active=move || image_toolbar_anchor.get().map(|anchor| anchor.align == "right").unwrap_or(false).to_string()
title="右对齐"
aria-label="右对齐"
on:click=move |_| {
match apply_image_align(editor, "right") {
Ok(message) => {
set_image_toolbar_anchor.update(|state| {
if let Some(anchor) = state.as_mut() {
anchor.align = "right".to_string();
}
});
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,
message,
);
}
Err(err) => set_command_feedback.set(err),
}
}
>"右"</button>
<span class="image-toolbar-separator" aria-hidden="true"></span>
<button
type="button"
class="image-toolbar-btn"
data-testid="image-download"
title="下载图片"
aria-label="下载图片"
on:click=move |_| {
if download_mnote_selected_image() {
set_command_feedback.set("已开始下载图片".to_string());
} else {
set_command_feedback.set("图片下载失败".to_string());
}
}
>"下"</button>
<button
type="button"
class="image-toolbar-btn"
data-testid="image-delete"
title="删除图片"
aria-label="删除图片"
disabled=true
>"删"</button>
</div>
}.into_any()
}}
{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 || {
if block_keyboard_mode.get()
&& editor_focused.get()
&& !text_selection_active.get()
&& !slash_open.get()
&& !block_menu_open.get()
{
if let Some(block) = hovered_block.get().or_else(hovered_block_from_selection) {
let outline_top = format!("{}px", (block.top - 2.0).max(0.0));
let outline_height = format!("{}px", (block.height + 4.0).max(30.0));
return view! {
<div
class="block-layout-outline"
data-testid="block-layout-outline"
style:top=outline_top
style:min-height=outline_height
></div>
}.into_any();
}
}
().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", block_menu_overlay::hover_anchor_top(&block));
let anchor_transform = block_menu_overlay::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;
}
let same_anchor = block_menu_anchor
.get_untracked()
.map(|current| current.index == block_index)
.unwrap_or(false);
open_block_menu_overlay(
click_anchor_block.clone(),
same_anchor,
block_menu_open,
set_slash_open,
set_slash_index,
set_slash_query,
set_turn_into_open,
set_color_menu_open,
set_more_menu_open,
set_locked_toolbar_anchor,
set_text_selection_active,
set_floating_toolbar_anchor,
set_image_toolbar_anchor,
set_table_toolbar_open,
set_table_options_open,
set_table_overlay_anchor,
set_table_selection_overlay,
set_block_menu_open,
set_block_menu_anchor,
set_block_turn_into_open,
set_block_folded_title_open,
);
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 = editor_stage_element().and_then(|stage| {
let rect = stage.get_bounding_client_rect();
block_menu_overlay::block_menu_layout(&block, rect.width(), rect.height(), rect.top(), rect.left())
}).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();
request_ai_edit_bridge(
editor,
"ask_ai",
document_id,
workspace_id,
Some(block_index),
runtime_block_id_from_index(block_index),
selection_state,
set_ai_bridge_state,
set_ai_bridge_message,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
);
}
>
<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(&current_html, block_index) {
Ok(next_html) => {
apply_html_update(
editor,
&runtime_persisted_identity(document_id, workspace_id),
next_html,
document_id,
workspace_id,
dirty_count,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
hovered_block,
editor_focused,
slash_open,
turn_into_open,
color_menu_open,
more_menu_open,
revision,
conflict_detection_key,
read_only,
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:mousedown=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
match editor_runtime::history_safe_commands::delete_top_level_block_with_history(&editor, block_index) {
Ok(()) => {
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!("已删除 {}", delete_feedback_label.clone()),
);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_hovered_block.set(None);
schedule_editor_focus(editor);
}
Err(err) => set_command_feedback.set(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();
let Some(block_id) = runtime_block_id_from_index(block_index) else {
set_command_feedback.set("当前块缺少 Rust block id,无法复制锚点链接".to_string());
return;
};
let Some(anchor_url) = current_page_anchor_url(&block_id) else {
set_command_feedback.set("当前页面 URL 不可用,无法复制锚点链接".to_string());
return;
};
if let Some(block_element) = editor_block_by_id(&block_id) {
let _ = block_element.scroll_into_view_with_bool(true);
}
spawn_local(async move {
match write_mnote_text_to_clipboard(anchor_url.clone()).await {
Ok(value) if value.as_bool().unwrap_or(false) => {
set_command_feedback.set(format!("已复制块链接:{}", block_id));
}
_ => {
set_command_feedback.set(format!("块链接已生成但剪贴板写入失败:{}", anchor_url));
}
}
});
}
>
<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" data-e30-testid="block-turn-into-menu">
{SLASH_ACTIONS
.iter()
.filter(|action| !matches!(action.kind, SlashActionKind::Divider | SlashActionKind::SimpleTable | SlashActionKind::Image | SlashActionKind::UploadAttachment | SlashActionKind::Toc | 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 is_current = top_level_block_matches_action(editor, block_index, action.kind);
let shortcut = if is_current {
"✓"
} else {
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+-",
SlashActionKind::Mindmap => "/dt",
_ => "",
}
};
let testid = format!("block-transform-item-{}", action.id);
let action_menu_label = "块".to_string();
view! {
<button
class="block-drag-menu-item"
data-testid=testid
data-current=is_current.to_string()
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);
set_block_folded_title_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"
on:mouseenter=move |_| {
set_block_folded_title_open.set(true);
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_block_folded_title_open.set(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>
{move || {
if block_folded_title_open.get() {
view! {
<div class="block-drag-tertiary-submenu" data-testid="block-transform-folded-title-submenu">
{FOLDED_HEADING_ACTIONS
.iter()
.map(|folded| {
let action = *folded;
let testid = format!("block-transform-folded-heading-{}", action.id);
view! {
<button
class="block-drag-menu-item"
data-testid=testid
on:click=move |event: MouseEvent| {
event.stop_propagation();
match run_block_turn_into_folded_heading_action(editor, block_index, action.level) {
Ok(message) => {
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_block_turn_into_open.set(false);
set_block_folded_title_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)),
Err(err) => set_command_feedback.set(format!("{},但本地保存失败:{}", message, err)),
}
}
Err(err) => set_command_feedback.set(err),
}
}
>
<span class="block-drag-menu-icon">{action.icon}</span>
<span>{action.label}</span>
</button>
}
})
.collect_view()}
</div>
}.into_any()
} else {
().into_any()
}
}}
</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"
style=slash_menu_anchor_style()
>
<div class="slash-list">
{move || {
let query = slash_query.get().trim().to_ascii_lowercase();
let show_turn_into = !query.is_empty()
&& ("zhw".contains(query.as_str())
|| "转换为".contains(query.as_str()));
let show_page = !query.is_empty()
&& ("ym".contains(query.as_str())
|| "页面".contains(query.as_str()));
if show_turn_into || show_page {
view! {
<>
{if show_turn_into {
view! {
<>
<div class="slash-section-label">"转换为"</div>
<button
class="slash-item"
data-active="false"
data-testid="slash-item-turn-into"
on:click=move |_| {
set_command_feedback.set("转换为入口请通过块菜单或选区工具栏执行".to_string());
}
>
<span class="slash-item-row">
<span class="slash-item-icon">"↻"</span>
<span class="slash-item-copy">
<strong>"转换为"</strong>
<span>"打开块类型转换入口"</span>
</span>
<span class="slash-item-shortcut">"/zhw"</span>
</span>
</button>
</>
}.into_any()
} else {
().into_any()
}}
{if show_page {
view! {
<>
<div class="slash-section-label">"基础块列表"</div>
<button
class="slash-item"
data-active="false"
data-testid="slash-item-page"
on:click=move |_| {
set_command_feedback.set("页面入口请通过转换为页面真源链路执行".to_string());
}
>
<span class="slash-item-row">
<span class="slash-item-icon">"▣"</span>
<span class="slash-item-copy">
<strong>"页面"</strong>
<span>"创建或转换为页面块"</span>
</span>
<span class="slash-item-shortcut">"/ym"</span>
</span>
</button>
</>
}.into_any()
} else {
().into_any()
}}
</>
}.into_any()
} else {
().into_any()
}
}}
{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);
let slash_change_event_target = slash_change_event_target.clone();
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 |_| {
match run_slash_action(editor, kind) {
Ok(message) => {
set_slash_open.set(false);
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);
if let Some(target) = slash_change_event_target.as_ref() {
dispatch_change_event_to_target(
target,
&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_to_target(
target,
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),
) {
Ok(()) => set_command_feedback.set(format!("{message},并已写入本地草稿")),
Err(err) => set_command_feedback.set(format!("{message},但本地保存失败:{err}")),
}
}
Err(err) => {
set_slash_open.set(false);
set_command_feedback.set(format!("命令执行失败:{err}"));
}
}
}
>
<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=editor_instance_id.clone()
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);
schedule_scroll_mnote_block_anchor_from_hash();
if let Some(target) = ready_event_target.as_ref() {
dispatch_ready_event_to_target(
target,
&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_to_target(
target,
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);
if let Some(target) = change_event_target.as_ref() {
dispatch_change_event_to_target(
target,
&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_to_target(
target,
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}"));
}
}
schedule_scroll_mnote_block_anchor_from_hash();
}
on_selection_change=move |selection: TiptapSelectionState| {
let selection_clone = selection.clone();
set_selection_state.set(selection_clone.clone());
sync_overlays_on_selection_change(
turn_into_open,
color_menu_open,
more_menu_open,
text_selection_active,
set_text_selection_active,
set_floating_toolbar_anchor,
set_image_toolbar_anchor,
set_turn_into_open,
set_block_menu_open,
set_block_menu_anchor,
set_hovered_block,
);
if let Some(target) = selection_event_target.as_ref() {
send_selection_state_to_target(
target,
&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()/> }
});
}
// ── Phase BBlock Delta apply ──────────────────────────────
/// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。
/// 返回 true 表示树发生了变更。
fn apply_block_delta_to_json(content: &mut Value, operations: &[Value]) -> bool {
let Some(content_arr) = content.get_mut("content").and_then(Value::as_array_mut) else {
return false;
};
let mut changed = false;
for op_value in operations {
let Some(op) = op_value.get("op").and_then(Value::as_str) else {
continue;
};
match op {
"replace" => {
let block_id = op_value.get("block_id").and_then(Value::as_str);
let text = op_value.get("text").and_then(Value::as_str).unwrap_or("");
let block_type = op_value.get("block_type").and_then(Value::as_str);
if let Some(bid) = block_id {
if apply_replace_block(content_arr, bid, text, block_type) {
changed = true;
}
}
}
"insert_after" => {
let anchor = op_value.get("anchor_block_id").and_then(Value::as_str);
let block_id = op_value.get("block_id").and_then(Value::as_str);
let text = op_value.get("text").and_then(Value::as_str).unwrap_or("");
if let (Some(aid), Some(bid)) = (anchor, block_id) {
if apply_insert_block_after(content_arr, aid, bid, text) {
changed = true;
}
}
}
"delete" => {
let block_id = op_value.get("block_id").and_then(Value::as_str);
if let Some(bid) = block_id {
if apply_delete_block(content_arr, bid) {
changed = true;
}
}
}
"move_after" => {
let block_id = op_value.get("block_id").and_then(Value::as_str);
let anchor = op_value.get("anchor_block_id").and_then(Value::as_str);
if let (Some(bid), Some(aid)) = (block_id, anchor) {
if apply_move_block_after(content_arr, bid, aid) {
changed = true;
}
}
}
_ => {}
}
}
changed
}
fn find_block_index(blocks: &[Value], block_id: &str) -> Option<usize> {
blocks.iter().position(|b| {
b.get("attrs")
.and_then(|a| a.get("block_id"))
.and_then(Value::as_str)
== Some(block_id)
})
}
fn apply_replace_block(
blocks: &mut Vec<Value>,
block_id: &str,
text: &str,
block_type: Option<&str>,
) -> bool {
let Some(idx) = find_block_index(blocks, block_id) else { return false; };
let block = &mut blocks[idx];
// 更新 block type
if let Some(bt) = block_type {
if let Some(b) = block.as_object_mut() {
b.insert("type".into(), json!(bt));
}
}
// 更新 text content
let new_content: Value = if text.is_empty() {
json!([])
} else {
json!([{ "type": "text", "text": text }])
};
if let Some(b) = block.as_object_mut() {
b.insert("content".into(), new_content);
}
true
}
fn apply_insert_block_after(
blocks: &mut Vec<Value>,
anchor_block_id: &str,
new_block_id: &str,
text: &str,
) -> bool {
let Some(idx) = find_block_index(blocks, anchor_block_id) else { return false; };
let new_block = json!({
"type": "paragraph",
"attrs": { "block_id": new_block_id },
"content": if text.is_empty() {
json!([])
} else {
json!([{ "type": "text", "text": text }])
}
});
blocks.insert(idx + 1, new_block);
true
}
fn apply_delete_block(blocks: &mut Vec<Value>, block_id: &str) -> bool {
let Some(idx) = find_block_index(blocks, block_id) else { return false; };
blocks.remove(idx);
true
}
fn apply_move_block_after(
blocks: &mut Vec<Value>,
block_id: &str,
anchor_block_id: &str,
) -> bool {
let Some(block_idx) = find_block_index(blocks, block_id) else { return false; };
let Some(anchor_idx) = find_block_index(blocks, anchor_block_id) else { return false; };
// Can't move to itself or anchor after block
if block_idx == anchor_idx || block_idx == anchor_idx + 1 {
return false;
}
let block = blocks.remove(block_idx);
// After removal, anchor may have shifted if block was before anchor
let adjusted_anchor = if block_idx < anchor_idx {
anchor_idx - 1
} else {
anchor_idx
};
blocks.insert(adjusted_anchor + 1, block);
true
}