10077 lines
417 KiB
Rust
10077 lines
417 KiB
Rust
use leptos::ev;
|
||
use leptos::mount::{mount_to, mount_to_body, UnmountHandle};
|
||
use leptos::prelude::*;
|
||
use leptos_dom::helpers::window_event_listener;
|
||
use leptos_tiptap::{
|
||
TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditor,
|
||
TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel, TiptapHighlightAttributes,
|
||
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: "上传 Office、PDF 或其他文件",
|
||
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 listener(Phase B:AI 写入增量通道) ──
|
||
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(
|
||
¤t_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(), ¤t_block) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
set_hovered_block.set(None);
|
||
}
|
||
on:mouseover=move |event: MouseEvent| {
|
||
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
|
||
return;
|
||
}
|
||
let Some(target) = event.target() else {
|
||
return;
|
||
};
|
||
if let Some(block) = hovered_block_from_target(target) {
|
||
set_editor_focused.set(true);
|
||
set_hovered_block.set(Some(block));
|
||
}
|
||
}
|
||
on:mouseleave=move |event: MouseEvent| {
|
||
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
|
||
return;
|
||
}
|
||
if let Some(current_block) = hovered_block.get_untracked() {
|
||
if pointer_in_handle_corridor(event.client_x(), event.client_y(), ¤t_block) {
|
||
return;
|
||
}
|
||
}
|
||
set_hovered_block.set(None);
|
||
}
|
||
on:dragover=move |event: DragEvent| {
|
||
if dragging_block_index.get_untracked().is_none() {
|
||
return;
|
||
}
|
||
|
||
event.prevent_default();
|
||
let Some(target) = event.target() else {
|
||
return;
|
||
};
|
||
|
||
if let Some(element) = target_element(target.clone()) {
|
||
if element_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(
|
||
¤t_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(¤t_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 B:Block 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
|
||
}
|