7952 lines
326 KiB
Rust
7952 lines
326 KiB
Rust
use leptos::ev;
|
||
use leptos::mount::{mount_to, mount_to_body, UnmountHandle};
|
||
use leptos::prelude::*;
|
||
use leptos_dom::helpers::window_event_listener;
|
||
use leptos_tiptap::{
|
||
TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditor,
|
||
TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel, TiptapHighlightAttributes,
|
||
TiptapInsertContentOptions, TiptapLinkResource, TiptapMarkName, 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,
|
||
MouseEvent, Node, RequestInit, RequestMode, Response, WheelEvent,
|
||
};
|
||
|
||
use editor_runtime::attachment_upload::dispatch_editor_upload_request;
|
||
use editor_runtime::block_dnd::{
|
||
block_state_from_index as block_state_from_index_runtime,
|
||
drop_indicator_from_point as drop_indicator_from_point_runtime,
|
||
drop_indicator_from_target as drop_indicator_from_target_runtime,
|
||
element_within_handle_shell, hovered_block_from_target as hovered_block_from_target_runtime,
|
||
};
|
||
use editor_runtime::block_menu_overlay;
|
||
use editor_runtime::block_menu_document::{
|
||
collect_plain_text, mindmap_paragraph_node, paragraph_node,
|
||
};
|
||
use editor_runtime::block_hover_state::{
|
||
BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState,
|
||
};
|
||
use editor_runtime::bridge_dispatch::HostCommandKind;
|
||
use editor_runtime::bridge_events::{
|
||
BridgeEnvelope, BridgeSelectorsPayload, ChangeMetaPayload, ChangePayload, HeightPayload,
|
||
HostCommandEnvelope, HostCommandPayload, HostStatusPayload, ReadyPayload, RuntimePageOptions,
|
||
StatePayload, BLOCK_DELTA_EVENT, CHANGE_EVENT, COMMAND_EVENT, EVENT_PREFIX, HEIGHT_EVENT,
|
||
PROTOCOL, READY_EVENT, RUNTIME_NAME, RUNTIME_VERSION, SELECTION_EVENT, STATE_EVENT,
|
||
STATUS_EVENT,
|
||
};
|
||
use editor_runtime::command_sync::{
|
||
read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command,
|
||
};
|
||
use editor_runtime::content_layout::{content_column_left, content_text_left};
|
||
use editor_runtime::dom_events::{
|
||
event_target_matches_selector, target_element, trap_scroll_inside_menu,
|
||
};
|
||
use editor_runtime::dom_selection::{
|
||
block_index_from_selection, current_block_info_from_index, 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::mindmap_node_view::{
|
||
mount_mindmap_shell_impl, unmount_mindmap_shell_impl,
|
||
};
|
||
use editor_runtime::overlays::{
|
||
clamp_overlay_anchor,
|
||
close_editor_floating_overlays_if_escape,
|
||
image_element_from_target, image_toolbar_anchor_from_image, open_block_menu_overlay,
|
||
open_image_toolbar_overlay, open_slash_menu_overlay, selection_bounding_rect,
|
||
should_auto_close_toolbar_overlays, sync_overlays_on_selection_change,
|
||
table_overlay_anchor_from_table, toolbar_overlay_locked, try_sync_editor_overlay_state,
|
||
FloatingToolbarAnchor, ImageToolbarAnchor, TableOverlayAnchor, TableSelectionOverlayState,
|
||
};
|
||
use editor_runtime::persistence::{
|
||
load_persisted_document, normalize_identity_value, persist_document_state,
|
||
persisted_document_identity, PersistedDocumentIdentity,
|
||
};
|
||
use editor_runtime::slash_actions::{SlashActionKind, SLASH_ACTIONS};
|
||
use editor_runtime::table_toolbar_view::table_toolbar_view;
|
||
#[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 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;
|
||
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)]
|
||
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: "折叠小标题",
|
||
},
|
||
];
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
#[allow(dead_code)]
|
||
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>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[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!(SPIKE_STYLE.contains(".slash-menu"));
|
||
assert!(SPIKE_STYLE.contains("position: fixed;"));
|
||
assert!(SPIKE_STYLE.contains("z-index: 130;"));
|
||
assert!(!SPIKE_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)]
|
||
#[allow(dead_code)]
|
||
struct RuntimeMountOptions {
|
||
options: MountOptions,
|
||
mode: RuntimeDeliveryMode,
|
||
}
|
||
|
||
struct MountedRuntimeListener {
|
||
target: EventTarget,
|
||
listener: Closure<dyn FnMut(Event)>,
|
||
}
|
||
|
||
struct MountedRuntime {
|
||
listeners: Vec<MountedRuntimeListener>,
|
||
// 保留 Leptos mount handle,避免挂载内容被提前释放。
|
||
#[allow(dead_code)]
|
||
mount_handle: Box<dyn Any>,
|
||
}
|
||
|
||
|
||
impl Drop for MountedRuntime {
|
||
fn drop(&mut self) {
|
||
for listener in &self.listeners {
|
||
let _ = listener.target.remove_event_listener_with_callback(
|
||
COMMAND_EVENT,
|
||
listener.listener.as_ref().unchecked_ref(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
thread_local! {
|
||
static RUNTIME_MOUNT_CONTEXT: std::cell::RefCell<Option<RuntimeMountContext>> = const { std::cell::RefCell::new(None) };
|
||
static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell<Option<RuntimeMountOptions>> = const { std::cell::RefCell::new(None) };
|
||
static MOUNTED_HANDLES: std::cell::RefCell<HashMap<u32, MountedRuntime>> = std::cell::RefCell::new(HashMap::new());
|
||
static PENDING_RUNTIME_LISTENERS: std::cell::RefCell<HashMap<u32, Vec<MountedRuntimeListener>>> = std::cell::RefCell::new(HashMap::new());
|
||
static NEXT_MOUNT_ID: Cell<u32> = const { Cell::new(1) };
|
||
}
|
||
|
||
fn next_mount_id() -> u32 {
|
||
NEXT_MOUNT_ID.with(|cell| {
|
||
let next = cell.get();
|
||
cell.set(next.saturating_add(1).max(1));
|
||
next
|
||
})
|
||
}
|
||
|
||
fn set_runtime_mount_context(context: Option<RuntimeMountContext>) {
|
||
RUNTIME_MOUNT_CONTEXT.with(|cell| {
|
||
*cell.borrow_mut() = context;
|
||
});
|
||
}
|
||
|
||
fn runtime_mount_context() -> Option<(u32, EventTarget, RuntimeDeliveryMode)> {
|
||
RUNTIME_MOUNT_CONTEXT.with(|cell| {
|
||
cell.borrow()
|
||
.as_ref()
|
||
.map(|context| (context.id, context.target.clone(), context.mode))
|
||
})
|
||
}
|
||
|
||
fn runtime_delivery_mode() -> RuntimeDeliveryMode {
|
||
resolve_runtime_delivery_mode(
|
||
runtime_mount_context().map(|(_, _, mode)| mode),
|
||
url_requests_embedded_mode(),
|
||
)
|
||
}
|
||
|
||
fn set_runtime_mount_options(options: Option<RuntimeMountOptions>) {
|
||
RUNTIME_MOUNT_OPTIONS.with(|cell| {
|
||
*cell.borrow_mut() = options;
|
||
});
|
||
}
|
||
|
||
fn runtime_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_to_target(target: &EventTarget, payload: &ChangePayload) {
|
||
dispatch_custom_event_to_target(target, CHANGE_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_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))
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
pub fn mount_mindmap_shell(container: Element, options: JsValue) -> Result<u32, JsValue> {
|
||
console_error_panic_hook::set_once();
|
||
mount_mindmap_shell_impl(container.into(), options)
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
pub fn unmount_mindmap_shell(mount_id: u32) -> Result<(), JsValue> {
|
||
unmount_mindmap_shell_impl(mount_id)
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
pub fn mount(container: Element, options: JsValue) -> Result<u32, JsValue> {
|
||
console_error_panic_hook::set_once();
|
||
let options = if options.is_undefined() || options.is_null() {
|
||
MountOptions::default()
|
||
} else {
|
||
serde_wasm_bindgen::from_value(options)?
|
||
};
|
||
let target = container
|
||
.dyn_into::<HtmlElement>()
|
||
.map_err(|_| JsValue::from_str("mount 目标必须是 HTML 元素"))?;
|
||
let mount_id = mount_app_into(target, options, RuntimeDeliveryMode::Embedded);
|
||
Ok(mount_id)
|
||
}
|
||
|
||
#[wasm_bindgen]
|
||
pub fn unmount(mount_id: u32) -> Result<(), JsValue> {
|
||
if let Some(handle) = take_unmount_handle(mount_id) {
|
||
if runtime_mount_context().map(|(id, _, _)| id) == Some(mount_id) {
|
||
set_runtime_mount_context(None);
|
||
set_runtime_mount_options(None);
|
||
}
|
||
drop(handle);
|
||
Ok(())
|
||
} else {
|
||
Err(JsValue::from_str("找不到对应的挂载句柄"))
|
||
}
|
||
}
|
||
|
||
fn mount_app_into(target: HtmlElement, options: MountOptions, mode: RuntimeDeliveryMode) -> u32 {
|
||
let mount_id = next_mount_id();
|
||
let context_target: EventTarget = target.clone().into();
|
||
let mount_options = RuntimeMountOptions {
|
||
options: options.clone(),
|
||
mode,
|
||
};
|
||
|
||
set_runtime_mount_options(Some(mount_options.clone()));
|
||
set_runtime_mount_context(Some(RuntimeMountContext {
|
||
id: mount_id,
|
||
target: context_target.clone(),
|
||
mode,
|
||
}));
|
||
|
||
let handle = mount_to(target, move || {
|
||
view! {
|
||
<App mount_options=options />
|
||
}
|
||
});
|
||
|
||
let command_listener = build_command_listener(mount_id);
|
||
let _ = context_target
|
||
.add_event_listener_with_callback(COMMAND_EVENT, command_listener.as_ref().unchecked_ref());
|
||
|
||
register_unmount_handle(mount_id, context_target, command_listener, handle);
|
||
mount_id
|
||
}
|
||
|
||
fn build_command_listener(mount_id: u32) -> Closure<dyn FnMut(Event)> {
|
||
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
|
||
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
|
||
return;
|
||
};
|
||
let detail = custom_event.detail();
|
||
let Ok(envelope) = serde_wasm_bindgen::from_value::<HostCommandEnvelope>(detail) else {
|
||
return;
|
||
};
|
||
if envelope.protocol.as_deref() != Some(PROTOCOL) {
|
||
return;
|
||
}
|
||
|
||
let Some(payload) = envelope.payload else {
|
||
return;
|
||
};
|
||
let Some(command_kind) = HostCommandKind::from_payload(&payload) else {
|
||
return;
|
||
};
|
||
|
||
let _ = mount_id;
|
||
let _ = command_kind;
|
||
}))
|
||
}
|
||
|
||
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 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 hovered_block_from_target(target: web_sys::EventTarget) -> Option<HoveredBlockState> {
|
||
let root = editor_root_element()?;
|
||
hovered_block_from_target_runtime(target, &root, HANDLE_SHELL_SELECTOR)
|
||
}
|
||
|
||
fn hovered_block_from_selection() -> Option<HoveredBlockState> {
|
||
block_index_from_selection().and_then(block_state_from_index)
|
||
}
|
||
|
||
fn block_state_from_index(index: usize) -> Option<HoveredBlockState> {
|
||
block_state_from_index_runtime(index)
|
||
}
|
||
|
||
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 selection_rect = selection_bounding_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_top, clamped_left) = clamp_overlay_anchor(
|
||
raw_top,
|
||
raw_left,
|
||
raw_anchor_top,
|
||
MENU_WIDTH,
|
||
MENU_HEIGHT,
|
||
GAP,
|
||
);
|
||
|
||
format!("top:{clamped_top:.1}px;left:{clamped_left:.1}px;")
|
||
}
|
||
|
||
fn drop_indicator_from_target(
|
||
target: web_sys::EventTarget,
|
||
client_y: i32,
|
||
) -> Option<DropIndicatorState> {
|
||
drop_indicator_from_target_runtime(target, client_y, HANDLE_SHELL_SELECTOR)
|
||
}
|
||
|
||
fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option<DropIndicatorState> {
|
||
drop_indicator_from_point_runtime(client_x, client_y, HANDLE_SHELL_SELECTOR)
|
||
}
|
||
|
||
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 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 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_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}"))
|
||
}
|
||
|
||
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_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 |_| {
|
||
if should_auto_close_toolbar_overlays(
|
||
editor_focused.get(),
|
||
text_selection_active.get(),
|
||
turn_into_open.get_untracked(),
|
||
color_menu_open.get_untracked(),
|
||
more_menu_open.get_untracked(),
|
||
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_runtime::history_safe_commands::move_top_level_block_with_history(
|
||
&editor,
|
||
source_index,
|
||
indicator.index,
|
||
indicator.placement,
|
||
) {
|
||
Ok(()) => {
|
||
sync_persisted_editor_command(
|
||
editor,
|
||
&persisted_identity,
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_document_json,
|
||
set_json_output,
|
||
title,
|
||
set_command_feedback,
|
||
"已通过块手柄拖拽重排",
|
||
);
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(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 editor_runtime::block_dnd::pointer_in_handle_corridor(
|
||
event.client_x(),
|
||
event.client_y(),
|
||
¤t_block,
|
||
HANDLE_SHELL_SELECTOR,
|
||
handle_lane_left(
|
||
editor_stage_element()
|
||
.map(|stage| stage.get_bounding_client_rect().width())
|
||
.unwrap_or(0.0),
|
||
),
|
||
HANDLE_TRIGGER_WIDTH,
|
||
) {
|
||
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 editor_runtime::block_dnd::pointer_in_handle_corridor(
|
||
event.client_x(),
|
||
event.client_y(),
|
||
¤t_block,
|
||
HANDLE_SHELL_SELECTOR,
|
||
handle_lane_left(
|
||
editor_stage_element()
|
||
.map(|stage| stage.get_bounding_client_rect().width())
|
||
.unwrap_or(0.0),
|
||
),
|
||
HANDLE_TRIGGER_WIDTH,
|
||
) {
|
||
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_runtime::history_safe_commands::move_top_level_block_with_history(
|
||
&editor,
|
||
source_index,
|
||
indicator.index,
|
||
indicator.placement,
|
||
) {
|
||
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,
|
||
"已通过块手柄拖拽重排",
|
||
);
|
||
}
|
||
Err(err) => {
|
||
set_command_feedback.set(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);
|
||
}
|
||
>
|
||
{table_toolbar_view(
|
||
editor,
|
||
table_toolbar_open,
|
||
set_table_toolbar_open,
|
||
table_options_open,
|
||
set_table_options_open,
|
||
table_overlay_anchor,
|
||
set_table_overlay_anchor,
|
||
table_selection_overlay,
|
||
set_table_selection_overlay,
|
||
slash_open,
|
||
block_menu_open,
|
||
dragging_block_index,
|
||
document_json,
|
||
set_document_json,
|
||
set_dirty_count,
|
||
set_html_output,
|
||
set_json_output,
|
||
title,
|
||
document_id,
|
||
workspace_id,
|
||
set_command_feedback,
|
||
)}
|
||
{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 should_auto_close_toolbar_overlays(
|
||
editor_focused.get(),
|
||
text_selection_active.get(),
|
||
turn_into_open.get(),
|
||
color_menu_open.get(),
|
||
more_menu_open.get(),
|
||
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_runtime::history_safe_commands::duplicate_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!("已复制 {}", duplicate_feedback_label.clone()),
|
||
);
|
||
set_block_menu_open.set(false);
|
||
set_block_menu_anchor.set(None);
|
||
}
|
||
Err(err) => set_command_feedback.set(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
|
||
}
|