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

5210 lines
268 KiB
Rust
Raw Normal View History

use leptos::ev;
2026-05-26 03:12:59 +08:00
use leptos::mount::{mount_to, mount_to_body};
use leptos::prelude::*;
use leptos_dom::helpers::window_event_listener;
use leptos_tiptap::{
TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditor,
TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel, TiptapHighlightAttributes,
2026-05-25 20:17:20 +08:00
TiptapInsertContentOptions, TiptapLinkResource, TiptapMarkName, TiptapNodeName, TiptapRange,
TiptapSchemaTarget, TiptapSelectionState, TiptapTextAlign, TiptapTocNodeAttrs,
};
use send_wrapper::SendWrapper;
2026-05-26 03:12:59 +08:00
use serde::Deserialize;
2026-05-25 06:27:00 +08:00
use serde_json::{json, Value};
2026-05-26 03:12:59 +08:00
use std::{cell::Cell, fmt::Display};
use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
2026-04-30 22:45:43 +08:00
use wasm_bindgen_futures::{spawn_local, JsFuture};
use web_sys::{
window, CustomEvent, CustomEventInit, DragEvent, Element, Event, EventTarget, HtmlElement,
2026-05-25 19:31:55 +08:00
MouseEvent, Node, RequestInit, RequestMode, Response, WheelEvent,
};
2026-05-24 23:24:38 +08:00
use editor_runtime::attachment_upload::dispatch_editor_upload_request;
2026-05-25 03:05:57 +08:00
use editor_runtime::block_dnd::{
2026-05-25 08:54:27 +08:00
block_state_from_index as block_state_from_index_runtime,
drop_indicator_from_point as drop_indicator_from_point_runtime,
2026-05-26 03:12:59 +08:00
drop_indicator_from_target as drop_indicator_from_target_runtime, element_within_handle_shell,
hovered_block_from_target as hovered_block_from_target_runtime,
2026-05-25 06:27:00 +08:00
};
2026-05-25 02:34:01 +08:00
use editor_runtime::block_hover_state::{
2026-05-25 08:12:51 +08:00
BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState,
2026-05-25 02:34:01 +08:00
};
2026-05-26 03:12:59 +08:00
use editor_runtime::block_menu_document::{
collect_plain_text, mindmap_paragraph_node, paragraph_node,
};
use editor_runtime::block_menu_overlay;
use editor_runtime::block_transform::apply_block_delta_to_json;
2026-05-25 19:50:50 +08:00
use editor_runtime::bridge_dispatch::HostCommandKind;
2026-05-25 19:31:55 +08:00
use editor_runtime::bridge_events::{
2026-05-26 03:12:59 +08:00
BridgeSelectorsPayload, ChangeMetaPayload, ChangePayload, HeightPayload, HostCommandEnvelope,
HostCommandPayload, HostStatusPayload, ReadyPayload, RuntimePageOptions, StatePayload,
BLOCK_DELTA_EVENT, COMMAND_EVENT, HEIGHT_EVENT, PROTOCOL, RUNTIME_NAME,
2026-05-25 19:31:55 +08:00
};
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};
2026-05-25 09:44:28 +08:00
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,
2026-05-26 03:12:59 +08:00
selection_payload, selection_summary,
2026-05-25 01:06:37 +08:00
};
2026-05-25 02:19:57 +08:00
use editor_runtime::editor_focus::{active_editor_stage, body_has_focus, schedule_editor_focus};
2026-05-26 03:12:59 +08:00
use editor_runtime::mindmap_node_view::{mount_mindmap_shell_impl, unmount_mindmap_shell_impl};
2026-05-25 01:06:37 +08:00
use editor_runtime::overlays::{
2026-05-26 03:12:59 +08:00
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,
};
2026-05-26 03:12:59 +08:00
#[cfg(test)]
use editor_runtime::persistence::persisted_document_storage_key;
use editor_runtime::persistence::{
load_persisted_document, normalize_identity_value, persist_document_state,
persisted_document_identity, PersistedDocumentIdentity,
};
2026-05-26 03:12:59 +08:00
use editor_runtime::runtime_bridge::{
build_command_listener, dispatch_change_event_to_target, dispatch_ready_event_to_target,
dispatch_runtime_event, dispatch_selection_event_to_target, dispatch_state_event_to_target,
dispatch_status_event_to_target, register_runtime_listener, register_unmount_handle,
take_unmount_handle,
};
2026-05-25 19:50:50 +08:00
use editor_runtime::slash_actions::{SlashActionKind, SLASH_ACTIONS};
2026-05-26 03:03:27 +08:00
use editor_runtime::style::SPIKE_STYLE;
2026-05-25 20:40:55 +08:00
use editor_runtime::table_toolbar_view::table_toolbar_view;
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;
2026-04-30 21:50:40 +08:00
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;
2026-05-02 06:25:26 +08:00
#[wasm_bindgen(inline_js = r#"
2026-05-06 21:44:20 +08:00
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;
}
2026-05-02 06:25:26 +08:00
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;
}
}
2026-05-06 21:44:20 +08:00
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 || '{}';
}
2026-05-02 06:25:26 +08:00
"#)]
extern "C" {
2026-05-06 21:44:20 +08:00
#[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>;
2026-05-02 06:25:26 +08:00
fn select_mnote_image_node(image: &Element) -> bool;
fn download_mnote_selected_image() -> bool;
2026-05-06 21:44:20 +08:00
fn bump_mnote_e27_ai_bridge_request_count() -> u32;
#[wasm_bindgen(catch)]
async fn post_mnote_document_save(body: String) -> Result<JsValue, JsValue>;
2026-05-02 06:25:26 +08:00
}
#[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,
},
];
2026-05-02 06:25:26 +08:00
#[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")]
2026-05-25 20:17:20 +08:00
#[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() {
2026-04-30 21:50:40 +08:00
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)
);
}
2026-05-13 22:43:16 +08:00
#[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>,
2026-05-13 22:43:16 +08:00
#[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)]
2026-05-25 20:17:20 +08:00
#[allow(dead_code)]
struct RuntimeMountOptions {
options: MountOptions,
mode: RuntimeDeliveryMode,
}
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 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())
}
2026-05-06 21:44:20 +08:00
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();
}
#[wasm_bindgen]
pub fn mount_mindmap_shell(container: Element, options: JsValue) -> Result<u32, JsValue> {
console_error_panic_hook::set_once();
2026-05-25 19:31:55 +08:00
mount_mindmap_shell_impl(container.into(), options)
}
#[wasm_bindgen]
pub fn unmount_mindmap_shell(mount_id: u32) -> Result<(), JsValue> {
2026-05-25 19:31:55 +08:00
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);
2026-04-30 21:50:40 +08:00
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 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")),
)
}
2026-05-13 22:43:16 +08:00
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 {
2026-04-30 21:50:40 +08:00
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())
}
2026-05-25 01:06:37 +08:00
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 {
2026-04-30 21:50:40 +08:00
(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> {
2026-05-25 08:54:27 +08:00
block_index_from_selection().and_then(block_state_from_index)
}
fn block_state_from_index(index: usize) -> Option<HoveredBlockState> {
2026-05-25 08:12:51 +08:00
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;
2026-05-25 08:54:27 +08:00
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)
};
2026-05-25 08:54:27 +08:00
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 {
2026-05-20 10:43:38 +08:00
let now = js_sys::Date::new_0();
format!(
"思维导图{:02}{:02}{:02}.json",
now.get_hours(),
now.get_minutes(),
now.get_seconds()
)
}
2026-04-30 21:50:40 +08:00
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;
2026-04-30 21:50:40 +08:00
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;
}
2026-04-30 21:50:40 +08:00
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,
2026-05-02 06:25:26 +08:00
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}"))
}
2026-05-02 06:25:26 +08:00
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 {
2026-04-30 21:50:40 +08:00
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),
2026-04-30 21:50:40 +08:00
SlashActionKind::Heading4 => editor.toggle_heading(TiptapHeadingLevel::H4),
SlashActionKind::BulletList => editor.toggle_bullet_list(),
SlashActionKind::OrderedList => editor.toggle_ordered_list(),
2026-04-30 21:50:40 +08:00
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(),
2026-05-02 06:25:26 +08:00
SlashActionKind::SimpleTable => {
let _ = editor.focus();
editor.insert_table(4, 3, false)
}
2026-05-26 03:12:59 +08:00
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("已打开附件上传");
}
2026-05-02 06:25:26 +08:00
SlashActionKind::Toc => editor.insert_toc_node(TiptapTocNodeAttrs {
top_offset: Some(0),
max_show_count: Some(20),
show_title: Some(true),
}),
};
result
.map(|_| match action {
2026-04-30 21:50:40 +08:00
SlashActionKind::AiAssistant
| SlashActionKind::AiWrite
| SlashActionKind::ContinueWriting
| SlashActionKind::Summarize
| SlashActionKind::MoreAi => "AI 命令入口已打开",
SlashActionKind::Paragraph => "已切到文本",
SlashActionKind::Heading1 => "已切到一级标题",
SlashActionKind::Heading2 => "已切到二级标题",
SlashActionKind::Heading3 => "已切到三级标题",
2026-04-30 21:50:40 +08:00
SlashActionKind::Heading4 => "已切到四级标题",
SlashActionKind::BulletList => "已切到无序列表",
SlashActionKind::OrderedList => "已切到有序列表",
2026-04-30 21:50:40 +08:00
SlashActionKind::Todo | SlashActionKind::AdvancedTodo => "已切到待办列表",
SlashActionKind::Quote => "已切到引述文字",
SlashActionKind::CodeBlock => "已切到代码块",
SlashActionKind::Divider => "已插入分割线",
2026-05-02 06:25:26 +08:00
SlashActionKind::SimpleTable => "已插入简单表格",
SlashActionKind::Mindmap => "已插入思维导图",
SlashActionKind::Image => "已打开图片上传",
SlashActionKind::UploadAttachment => "已打开附件上传",
2026-05-02 06:25:26 +08:00
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 {
2026-05-02 06:25:26 +08:00
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
2026-05-25 06:27:00 +08:00
.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)
}
}
}
2026-05-02 06:25:26 +08:00
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 => "已切到折叠小标题",
_ => "已切到折叠标题",
})
}
2026-04-30 22:14:37 +08:00
fn normalized_page_block_title(text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
"未命名页面".to_string()
} else {
trimmed.to_string()
}
}
2026-04-30 22:45:43 +08:00
#[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)
}
2026-04-30 22:14:37 +08:00
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)
}
2026-05-02 06:25:26 +08:00
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")
}
2026-05-02 06:25:26 +08:00
SlashActionKind::Image => node_type == "image",
SlashActionKind::UploadAttachment => false,
2026-05-02 06:25:26 +08:00
SlashActionKind::Toc => node_type == "tocNode",
SlashActionKind::AiAssistant
| SlashActionKind::AiWrite
| SlashActionKind::ContinueWriting
| SlashActionKind::Summarize
| SlashActionKind::MoreAi => false,
}
}
2026-04-30 22:45:43 +08:00
fn page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, title: &str) -> Value {
2026-04-30 22:14:37 +08:00
let href = if page_id.trim().is_empty() {
"#".to_string()
2026-04-30 22:45:43 +08:00
} else if let Some(workspace_id) = workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("/documents/{page_id}?workspaceId={workspace_id}")
2026-04-30 22:14:37 +08:00
} else {
format!("/documents/{page_id}")
};
paragraph_node(vec![json!({
"type": "text",
"text": normalized_page_block_title(title),
"marks": [{
"type": "link",
"attrs": {
"href": href,
2026-05-02 06:25:26 +08:00
"target": "_self",
2026-04-30 22:14:37 +08:00
"rel": "noopener noreferrer nofollow",
"class": "mnote-page-block-link",
}
}]
})])
}
fn replace_block_with_page_reference(
editor: TiptapEditorHandle,
block_index: usize,
page_id: &str,
2026-04-30 22:45:43 +08:00
workspace_id: Option<&str>,
2026-04-30 22:14:37 +08:00
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));
}
2026-04-30 22:45:43 +08:00
content[block_index] = page_reference_paragraph_node(page_id, workspace_id, title);
2026-04-30 22:14:37 +08:00
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;
}
};
2026-04-30 22:45:43 +08:00
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),
2026-04-30 22:14:37 +08:00
}
2026-04-30 22:45:43 +08:00
});
2026-04-30 22:14:37 +08:00
}
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));
}
2026-05-16 12:34:48 +08:00
fn open_hermes_page_ai_drawer() {
let Some(win) = window() else {
return;
2026-05-06 21:44:20 +08:00
};
2026-05-16 12:34:48 +08:00
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();
2026-05-06 21:44:20 +08:00
}
}
}
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>,
) {
2026-05-16 12:34:48 +08:00
let _ = (
2026-05-06 21:44:20 +08:00
editor,
action,
2026-05-16 12:34:48 +08:00
document_id,
workspace_id,
2026-05-06 21:44:20 +08:00
selected_block_index,
selected_block_id,
2026-05-16 12:34:48 +08:00
selection_state,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
2026-05-06 21:44:20 +08:00
);
2026-05-16 12:34:48 +08:00
let _ = (
set_ai_bridge_state,
set_ai_bridge_message,
set_command_feedback,
);
open_hermes_page_ai_drawer();
2026-05-06 21:44:20 +08:00
}
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");
2026-05-26 03:12:59 +08:00
let runtime_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);
2026-05-13 22:43:16 +08:00
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);
2026-05-13 22:43:16 +08:00
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()
2026-05-13 22:43:16 +08:00
.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)
2026-04-30 21:50:40 +08:00
.or_else(|| mount_options.content.clone().map(TiptapContent::json))
.or_else(|| {
2026-05-13 22:43:16 +08:00
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());
2026-05-13 22:43:16 +08:00
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);
2026-05-02 06:25:26 +08:00
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>);
2026-05-02 06:25:26 +08:00
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);
2026-05-02 06:25:26 +08:00
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>);
2026-04-30 21:50:40 +08:00
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);
2026-05-02 06:25:26 +08:00
let (block_folded_title_open, set_block_folded_title_open) = signal(false);
2026-05-16 12:34:48 +08:00
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();
2026-04-30 21:50:40 +08:00
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()),
);
2026-05-26 03:12:59 +08:00
let command_event_target = runtime_target.clone();
let ready_event_target = runtime_target.clone();
let change_event_target = runtime_target.clone();
let selection_event_target = runtime_target.clone();
let slash_change_event_target = runtime_target.clone();
let height_event_target = runtime_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 {
2026-05-26 03:12:59 +08:00
dispatch_runtime_event(
height_event_target.clone(),
HEIGHT_EVENT,
&HeightPayload { height },
);
}
});
}
{
let block_menu_open = block_menu_open;
let set_block_turn_into_open = set_block_turn_into_open;
2026-05-02 06:25:26 +08:00
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);
2026-05-02 06:25:26 +08:00
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();
2026-04-30 21:50:40 +08:00
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;
}
2026-04-30 21:50:40 +08:00
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;
};
2026-04-30 21:50:40 +08:00
let Some(payload) = envelope.payload else {
return;
};
2026-04-30 21:50:40 +08:00
match command_kind {
HostCommandKind::Undo => {
let _ = editor.undo();
}
2026-04-30 21:50:40 +08:00
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(),
);
}
2026-04-30 21:50:40 +08:00
}
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(),
),
},
);
}
2026-04-30 21:50:40 +08:00
}
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 }
]
2026-04-30 21:50:40 +08:00
})
};
let _ = editor.insert_content(TiptapContent::json(node), None);
}
}
2026-04-30 21:50:40 +08:00
}));
if let Some(document) = window().and_then(|win| win.document()) {
let listener_ref = command_listener.as_ref().unchecked_ref();
let _ = document.add_event_listener_with_callback(COMMAND_EVENT, listener_ref);
if let Some((mount_id, _, _)) = runtime_mount_context() {
let target: EventTarget = document.into();
register_runtime_listener(mount_id, target, command_listener);
}
}
// ── Block-delta listenerPhase BAI 写入增量通道) ──
let delta_editor = editor;
let delta_set_document_json = set_document_json;
let delta_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();
2026-05-26 03:12:59 +08:00
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
2026-05-26 03:12:59 +08:00
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 |_| {
2026-05-25 20:17:20 +08:00
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(),
2026-05-25 20:17:20 +08:00
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;
2026-05-02 06:25:26 +08:00
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;
2026-05-02 06:25:26 +08:00
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 {
2026-05-25 19:31:55 +08:00
match editor_runtime::history_safe_commands::move_top_level_block_with_history(
2026-05-26 03:12:59 +08:00
&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,
"已通过块手柄拖拽重排",
);
}
2026-05-26 03:12:59 +08:00
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
2026-05-02 06:25:26 +08:00
.closest(&format!("{HANDLE_SHELL_SELECTOR}, .floating-toolbar, .image-floating-toolbar, .table-toolbar, .table-controls"))
.ok()
.flatten()
.is_some()
{
return;
}
2026-05-02 06:25:26 +08:00
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;
2026-04-30 21:50:40 +08:00
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;
2026-05-02 06:25:26 +08:00
let slash_query = slash_query;
let set_slash_query = set_slash_query;
2026-05-06 21:44:20 +08:00
let turn_into_open = turn_into_open;
let set_turn_into_open = set_turn_into_open;
2026-05-06 21:44:20 +08:00
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;
2026-04-30 21:50:40 +08:00
let block_menu_open = block_menu_open;
let set_block_menu_anchor = set_block_menu_anchor;
2026-05-06 21:44:20 +08:00
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;
2026-05-26 03:12:59 +08:00
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}"));
}
}
2026-05-26 03:12:59 +08:00
},
)
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 {
2026-05-25 02:19:57 +08:00
if body_has_focus() && (event.ctrl_key() || event.meta_key()) && !event.alt_key() {
2026-05-25 02:03:47 +08:00
let key = event.key();
let undo_shortcut = !event.shift_key() && key.eq_ignore_ascii_case("z");
2026-05-26 03:12:59 +08:00
let redo_shortcut = key.eq_ignore_ascii_case("y")
|| (event.shift_key() && key.eq_ignore_ascii_case("z"));
2026-05-25 02:03:47 +08:00
if undo_shortcut || redo_shortcut {
event.prevent_default();
if redo_shortcut {
let _ = editor.redo();
} else {
let _ = editor.undo();
}
schedule_editor_focus(editor);
return;
}
}
return;
}
2026-05-02 06:25:26 +08:00
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" {
2026-04-30 21:50:40 +08:00
event.prevent_default();
2026-05-06 21:44:20 +08:00
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,
) {
2026-04-30 21:50:40 +08:00
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();
2026-05-06 21:44:20 +08:00
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,
);
2026-04-30 21:50:40 +08:00
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();
2026-04-30 21:50:40 +08:00
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);
}
2026-05-02 06:25:26 +08:00
"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);
}
2026-05-02 06:25:26 +08:00
_ => {
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));
}
2026-04-30 22:45:43 +08:00
{
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));
}
2026-05-06 21:44:20 +08:00
{
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;
}
}
2026-05-06 21:44:20 +08:00
if let Some(image) = image_element_from_target(target.clone()) {
2026-05-02 06:25:26 +08:00
let _ = select_mnote_image_node(&image);
set_editor_focused.set(true);
2026-05-06 21:44:20 +08:00
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,
);
2026-05-02 06:25:26 +08:00
return;
}
set_image_toolbar_anchor.set(None);
set_editor_focused.set(true);
2026-04-30 21:50:40 +08:00
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() {
2026-05-25 08:12:51 +08:00
if editor_runtime::block_dnd::pointer_in_handle_corridor(
event.client_x(),
event.client_y(),
&current_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));
}
}
2026-04-30 21:50:40 +08:00
on:mouseleave=move |event: MouseEvent| {
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
return;
}
2026-04-30 21:50:40 +08:00
if let Some(current_block) = hovered_block.get_untracked() {
2026-05-25 08:12:51 +08:00
if editor_runtime::block_dnd::pointer_in_handle_corridor(
event.client_x(),
event.client_y(),
&current_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,
) {
2026-04-30 21:50:40 +08:00
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;
};
2026-05-25 19:31:55 +08:00
match editor_runtime::history_safe_commands::move_top_level_block_with_history(
&editor,
source_index,
indicator.index,
indicator.placement,
) {
2026-05-25 19:31:55 +08:00
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);
}
>
2026-05-25 20:40:55 +08:00
{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,
)}
2026-05-02 06:25:26 +08:00
{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 {
2026-05-25 20:17:20 +08:00
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);
}
>
2026-04-30 21:50:40 +08:00
"文本"
</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
>
2026-04-30 21:50:40 +08:00
"链接"
</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);
}
>
2026-04-30 21:50:40 +08:00
"颜色"
</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);
}
>
2026-04-30 21:50:40 +08:00
"更多"
</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()
}
}}
2026-05-02 06:25:26 +08:00
{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(|| {
2026-04-30 21:50:40 +08:00
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();
}
2026-05-25 02:50:31 +08:00
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();
2026-04-30 21:50:40 +08:00
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" }
2026-04-30 21:50:40 +08:00
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
2026-04-30 21:50:40 +08:00
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);
2026-04-30 21:50:40 +08:00
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) => {
2026-04-30 21:50:40 +08:00
set_command_feedback.set(err);
}
}
}
>
2026-04-30 21:50:40 +08:00
<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);
2026-05-06 21:44:20 +08:00
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));
}
>
2026-04-30 21:50:40 +08:00
<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>
2026-04-30 21:50:40 +08:00
<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();
2026-05-25 02:50:31 +08:00
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,
2026-04-30 21:50:40 +08:00
top: 80.0,
left: 80.0,
});
2026-04-30 21:50:40 +08:00
let menu_top = format!("{}px", menu_layout.top);
let menu_bottom = "auto";
2026-04-30 22:14:37 +08:00
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
2026-04-30 21:50:40 +08:00
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);
}
>
2026-04-30 21:50:40 +08:00
<input
class="block-drag-command-input"
data-testid="block-drag-command-input"
readonly=true
placeholder="请输入指令"
/>
<div class="block-drag-menu-actions">
{move || {
2026-04-30 21:50:40 +08:00
let duplicate_feedback_label = current_duplicate_label.clone();
let delete_feedback_label = current_delete_label.clone();
view! {
<>
<div class="block-drag-menu-section">
<button
2026-04-30 21:50:40 +08:00
class="block-drag-menu-item"
data-testid="block-drag-menu-item-ai"
on:click=move |event: MouseEvent| {
event.stop_propagation();
2026-05-06 21:44:20 +08:00
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,
);
}
>
2026-04-30 21:50:40 +08:00
<span class="block-drag-menu-icon">"✦"</span>
<span>"AI 助理"</span>
<span class="block-drag-menu-shortcut">"Ctrl+J"</span>
</button>
2026-04-30 21:50:40 +08:00
<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();
2026-05-25 19:31:55 +08:00
match editor_runtime::history_safe_commands::duplicate_top_level_block_with_history(&editor, block_index) {
Ok(()) => {
sync_persisted_editor_command(
2026-04-30 21:50:40 +08:00
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"
2026-05-25 02:03:47 +08:00
on:mousedown=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
}
2026-04-30 21:50:40 +08:00
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);
2026-05-25 02:03:47 +08:00
schedule_editor_focus(editor);
}
Err(err) => set_command_feedback.set(err),
2026-04-30 21:50:40 +08:00
}
}
>
<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();
2026-05-06 21:44:20 +08:00
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));
}
}
});
2026-04-30 21:50:40 +08:00
}
>
<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! {
2026-05-06 21:44:20 +08:00
<div class="block-drag-submenu" data-testid="block-transform-submenu" data-e30-testid="block-turn-into-menu">
2026-04-30 21:50:40 +08:00
{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))
2026-04-30 21:50:40 +08:00
.map(|action| {
let kind = action.kind;
let label = action.label;
let icon = action.icon;
2026-05-02 06:25:26 +08:00
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",
2026-05-02 06:25:26 +08:00
_ => "",
}
2026-04-30 21:50:40 +08:00
};
let testid = format!("block-transform-item-{}", action.id);
let action_menu_label = "块".to_string();
view! {
<button
class="block-drag-menu-item"
data-testid=testid
2026-05-02 06:25:26 +08:00
data-current=is_current.to_string()
2026-04-30 21:50:40 +08:00
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);
2026-05-02 06:25:26 +08:00
set_block_folded_title_open.set(false);
2026-04-30 21:50:40 +08:00
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),
}
}
2026-04-30 21:50:40 +08:00
>
<span class="block-drag-menu-icon">{icon}</span>
<span>{label}</span>
<span class="block-drag-menu-shortcut">{shortcut}</span>
</button>
}
2026-04-30 21:50:40 +08:00
})
.collect_view()}
2026-04-30 22:14:37 +08:00
<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,
2026-05-02 06:25:26 +08:00
set_block_menu_open,
set_block_menu_anchor,
set_block_turn_into_open,
);
}
>
2026-04-30 22:14:37 +08:00
<span class="block-drag-menu-icon">"▣"</span>
2026-05-02 06:25:26 +08:00
<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()
2026-04-30 21:50:40 +08:00
} 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">
2026-05-02 06:25:26 +08:00
{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;
2026-04-30 21:50:40 +08:00
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! {
2026-04-30 21:50:40 +08:00
<>
{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 |_| {
2026-05-02 06:25:26 +08:00
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(),
);
}
2026-05-02 06:25:26 +08:00
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}"));
}
}
2026-04-30 21:50:40 +08:00
}
>
<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);
2026-05-06 21:44:20 +08:00
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}"));
}
}
2026-05-06 21:44:20 +08:00
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());
2026-05-25 05:18:25 +08:00
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()/> }
});
}