2026-04-22 05:57:06 +08:00
|
|
|
|
use leptos::ev;
|
2026-05-26 03:12:59 +08:00
|
|
|
|
use leptos::mount::{mount_to, mount_to_body};
|
2026-04-22 05:57:06 +08:00
|
|
|
|
use leptos::prelude::*;
|
|
|
|
|
|
use leptos_dom::helpers::window_event_listener;
|
|
|
|
|
|
use leptos_tiptap::{
|
2026-05-26 03:28:51 +08:00
|
|
|
|
TiptapContent, TiptapEditor, TiptapEditorHandle, TiptapExtension, TiptapLinkResource,
|
|
|
|
|
|
TiptapMarkName, TiptapSelectionState, TiptapTextAlign,
|
2026-04-22 05:57:06 +08:00
|
|
|
|
};
|
2026-05-24 19:55:37 +08:00
|
|
|
|
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};
|
2026-04-22 05:57:06 +08:00
|
|
|
|
use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
|
2026-05-26 03:28:51 +08:00
|
|
|
|
use wasm_bindgen_futures::spawn_local;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
use web_sys::{
|
2026-05-26 03:28:51 +08:00
|
|
|
|
window, CustomEvent, DragEvent, Element, Event, EventTarget, HtmlElement, MouseEvent,
|
|
|
|
|
|
WheelEvent,
|
2026-04-22 05:57:06 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
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,
|
2026-05-25 08:29:19 +08:00
|
|
|
|
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_overlay;
|
2026-05-26 03:21:11 +08:00
|
|
|
|
use editor_runtime::block_transform::{
|
|
|
|
|
|
apply_block_delta_to_json, apply_highlight_color, apply_image_align, apply_text_align,
|
2026-05-26 03:28:51 +08:00
|
|
|
|
apply_text_color, clear_text_color, insert_editor_paragraph_relative_to_block,
|
|
|
|
|
|
prosemirror_node_size, run_block_turn_into_action, run_block_turn_into_folded_heading_action,
|
|
|
|
|
|
run_block_turn_into_page_action, run_slash_action, top_level_block_boundary_position,
|
|
|
|
|
|
top_level_block_is_page, top_level_block_matches_action, top_level_block_range,
|
2026-05-26 03:21:11 +08:00
|
|
|
|
};
|
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
|
|
|
|
};
|
2026-05-24 23:12:29 +08:00
|
|
|
|
use editor_runtime::command_sync::{
|
|
|
|
|
|
read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command,
|
|
|
|
|
|
};
|
2026-05-25 09:17:48 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
2026-05-25 00:48:37 +08:00
|
|
|
|
use editor_runtime::dom_selection::{
|
2026-05-25 09:19:49 +08:00
|
|
|
|
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-25 00:48:37 +08:00
|
|
|
|
};
|
2026-05-26 03:12:59 +08:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
use editor_runtime::persistence::persisted_document_storage_key;
|
2026-05-24 23:12:29 +08:00
|
|
|
|
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;
|
2026-05-24 23:12:29 +08:00
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const EDITOR_STAGE_SELECTOR: &str = "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]";
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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;
|
2026-05-24 22:18:44 +08:00
|
|
|
|
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
|
|
|
|
}
|
2026-04-22 05:57:06 +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: "折叠小标题",
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
2026-05-25 20:17:20 +08:00
|
|
|
|
#[allow(dead_code)]
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
#[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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
#[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);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-20 11:19:42 +08:00
|
|
|
|
|
|
|
|
|
|
#[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)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-20 17:47:09 +08:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn slash_menu_css_uses_viewport_overlay_layer() {
|
2026-05-25 17:36:17 +08:00
|
|
|
|
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;"));
|
2026-05-20 17:47:09 +08:00
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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>,
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct RuntimeMountContext {
|
|
|
|
|
|
id: u32,
|
|
|
|
|
|
target: EventTarget,
|
|
|
|
|
|
mode: RuntimeDeliveryMode,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
2026-05-25 20:17:20 +08:00
|
|
|
|
#[allow(dead_code)]
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 12:27:40 +08:00
|
|
|
|
#[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)
|
2026-05-11 12:27:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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)
|
2026-05-11 12:27:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
#[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());
|
2026-04-22 05:57:06 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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())
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 00:48:37 +08:00
|
|
|
|
pub(crate) fn editor_root_element() -> Option<Element> {
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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> {
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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
|
2026-04-22 05:57:06 +08:00
|
|
|
|
- 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()?;
|
2026-05-25 08:20:41 +08:00
|
|
|
|
hovered_block_from_target_runtime(target, &root, HANDLE_SHELL_SELECTOR)
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn block_state_from_index(index: usize) -> Option<HoveredBlockState> {
|
2026-05-25 08:12:51 +08:00
|
|
|
|
block_state_from_index_runtime(index)
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 17:47:09 +08:00
|
|
|
|
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()
|
2026-05-20 17:47:09 +08:00
|
|
|
|
.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,
|
|
|
|
|
|
);
|
2026-05-20 17:47:09 +08:00
|
|
|
|
|
|
|
|
|
|
format!("top:{clamped_top:.1}px;left:{clamped_left:.1}px;")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
fn drop_indicator_from_target(
|
|
|
|
|
|
target: web_sys::EventTarget,
|
|
|
|
|
|
client_y: i32,
|
|
|
|
|
|
) -> Option<DropIndicatorState> {
|
2026-05-25 08:29:19 +08:00
|
|
|
|
drop_indicator_from_target_runtime(target, client_y, HANDLE_SHELL_SELECTOR)
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn drop_indicator_from_point(client_x: i32, client_y: i32) -> Option<DropIndicatorState> {
|
2026-05-25 08:29:19 +08:00
|
|
|
|
drop_indicator_from_point_runtime(client_x, client_y, HANDLE_SHELL_SELECTOR)
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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 {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let viewport_scroll = current_viewport_scroll();
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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());
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if let Some((x, y)) = viewport_scroll {
|
|
|
|
|
|
schedule_restore_viewport_scroll(x, y, 10);
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
set_command_feedback.set(format!("宿主文档同步失败:{err}"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn apply_feedback_result<E>(setter: WriteSignal<String>, result: Result<&'static str, E>)
|
|
|
|
|
|
where
|
|
|
|
|
|
E: Display,
|
|
|
|
|
|
{
|
|
|
|
|
|
match result {
|
|
|
|
|
|
Ok(message) => setter.set(message.to_string()),
|
|
|
|
|
|
Err(err) => setter.set(format!("命令执行失败:{err}")),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn apply_ok_feedback<E>(
|
|
|
|
|
|
setter: WriteSignal<String>,
|
|
|
|
|
|
result: Result<(), E>,
|
|
|
|
|
|
ok_message: &'static str,
|
|
|
|
|
|
) where
|
|
|
|
|
|
E: Display,
|
|
|
|
|
|
{
|
|
|
|
|
|
apply_feedback_result(setter, result.map(|_| ok_message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +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();
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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()
|
2026-05-08 23:15:00 +08:00
|
|
|
|
.map(|(_, target, _)| target)
|
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
|
window()
|
|
|
|
|
|
.and_then(|win| win.document())
|
|
|
|
|
|
.map(|document| document.into())
|
|
|
|
|
|
});
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
)
|
|
|
|
|
|
});
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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
|
|
|
|
|
|
};
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
2026-04-22 05:57:06 +08:00
|
|
|
|
.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))
|
2026-04-22 05:57:06 +08:00
|
|
|
|
.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
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
})
|
|
|
|
|
|
.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": []})
|
|
|
|
|
|
}
|
|
|
|
|
|
}));
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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>);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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>);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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());
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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());
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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),
|
|
|
|
|
|
);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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();
|
2026-04-22 05:57:06 +08:00
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
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 },
|
|
|
|
|
|
);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
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;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 |_| {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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-22 05:57:06 +08:00
|
|
|
|
|
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-22 05:57:06 +08:00
|
|
|
|
|
2026-04-30 21:50:40 +08:00
|
|
|
|
let Some(payload) = envelope.payload else {
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
2026-04-22 05:57:06 +08:00
|
|
|
|
|
2026-04-30 21:50:40 +08:00
|
|
|
|
match command_kind {
|
|
|
|
|
|
HostCommandKind::Undo => {
|
|
|
|
|
|
let _ = editor.undo();
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
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);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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),
|
|
|
|
|
|
);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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!({
|
2026-04-22 05:57:06 +08:00
|
|
|
|
"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-22 05:57:06 +08:00
|
|
|
|
}
|
2026-04-30 21:50:40 +08:00
|
|
|
|
}));
|
2026-04-22 05:57:06 +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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-16 22:03:14 +08:00
|
|
|
|
|
|
|
|
|
|
// ── Block-delta listener(Phase B:AI 写入增量通道) ──
|
|
|
|
|
|
let delta_editor = editor;
|
|
|
|
|
|
let delta_set_document_json = set_document_json;
|
|
|
|
|
|
let delta_set_json_output = set_json_output;
|
|
|
|
|
|
let delta_set_dirty_count = set_dirty_count;
|
|
|
|
|
|
let delta_set_html_output = set_html_output;
|
|
|
|
|
|
let delta_listener =
|
|
|
|
|
|
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
|
|
|
|
|
|
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
|
|
|
|
|
let detail = custom_event.detail();
|
2026-05-26 03:12:59 +08:00
|
|
|
|
let Ok(delta): Result<Value, _> = serde_wasm_bindgen::from_value(detail) else {
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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()
|
|
|
|
|
|
{
|
2026-05-16 22:03:14 +08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
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(),
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
) {
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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-04-22 05:57:06 +08:00
|
|
|
|
}
|
2026-05-26 03:12:59 +08:00
|
|
|
|
Err(err) => {
|
|
|
|
|
|
set_command_feedback.set(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
} 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"))
|
2026-04-22 05:57:06 +08:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
let set_block_menu_open = set_block_menu_open;
|
2026-04-30 21:50:40 +08:00
|
|
|
|
let block_menu_open = block_menu_open;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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| {
|
2026-05-24 19:55:37 +08:00
|
|
|
|
if !active_editor_stage()
|
|
|
|
|
|
|| event.ctrl_key()
|
|
|
|
|
|
|| event.meta_key()
|
|
|
|
|
|
|| event.alt_key()
|
|
|
|
|
|
|| !matches!(event.key().as_str(), "Backspace" | "Delete")
|
2026-05-24 22:18:44 +08:00
|
|
|
|
|| !editor_runtime::attachment_links::is_local_attachment_link_selected()
|
2026-05-24 19:55:37 +08:00
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
event.prevent_default();
|
|
|
|
|
|
event.stop_immediate_propagation();
|
2026-05-24 22:18:44 +08:00
|
|
|
|
match editor_runtime::history_safe_commands::delete_selected_attachment_link_with_history(
|
|
|
|
|
|
&editor,
|
|
|
|
|
|
) {
|
2026-05-24 19:55:37 +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,
|
|
|
|
|
|
"已删除附件引用",
|
|
|
|
|
|
);
|
|
|
|
|
|
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(_)>));
|
2026-05-24 19:55:37 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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();
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
"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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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
|
2026-05-08 23:15:00 +08:00
|
|
|
|
id=editor_stage_id.clone()
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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()) {
|
2026-05-25 06:09:42 +08:00
|
|
|
|
if element_within_handle_shell(&element, HANDLE_SHELL_SELECTOR) {
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
set_editor_focused.set(true);
|
2026-04-30 21:50:40 +08:00
|
|
|
|
set_block_keyboard_mode.set(false);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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()) {
|
2026-05-25 06:09:42 +08:00
|
|
|
|
if element_within_handle_shell(&element, HANDLE_SHELL_SELECTOR) {
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
¤t_block,
|
|
|
|
|
|
HANDLE_SHELL_SELECTOR,
|
|
|
|
|
|
handle_lane_left(
|
|
|
|
|
|
editor_stage_element()
|
|
|
|
|
|
.map(|stage| stage.get_bounding_client_rect().width())
|
|
|
|
|
|
.unwrap_or(0.0),
|
|
|
|
|
|
),
|
|
|
|
|
|
HANDLE_TRIGGER_WIDTH,
|
|
|
|
|
|
) {
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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-22 05:57:06 +08:00
|
|
|
|
}
|
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(),
|
|
|
|
|
|
¤t_block,
|
|
|
|
|
|
HANDLE_SHELL_SELECTOR,
|
|
|
|
|
|
handle_lane_left(
|
|
|
|
|
|
editor_stage_element()
|
|
|
|
|
|
.map(|stage| stage.get_bounding_client_rect().width())
|
|
|
|
|
|
.unwrap_or(0.0),
|
|
|
|
|
|
),
|
|
|
|
|
|
HANDLE_TRIGGER_WIDTH,
|
|
|
|
|
|
) {
|
2026-04-30 21:50:40 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
set_hovered_block.set(None);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
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()) {
|
2026-05-25 06:09:42 +08:00
|
|
|
|
if element_within_handle_shell(&element, HANDLE_SHELL_SELECTOR) {
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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,
|
2026-04-22 05:57:06 +08:00
|
|
|
|
source_index,
|
|
|
|
|
|
indicator.index,
|
|
|
|
|
|
indicator.placement,
|
|
|
|
|
|
) {
|
2026-05-25 19:31:55 +08:00
|
|
|
|
Ok(()) => {
|
|
|
|
|
|
sync_persisted_editor_command(
|
2026-04-22 05:57:06 +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,
|
|
|
|
|
|
"已通过块手柄拖拽重排",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
{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(),
|
|
|
|
|
|
)
|
2026-04-22 05:57:06 +08:00
|
|
|
|
|| 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
|
|
|
|
"文本"
|
2026-04-22 05:57:06 +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
|
|
|
|
"链接"
|
2026-04-22 05:57:06 +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
|
|
|
|
"颜色"
|
2026-04-22 05:57:06 +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
|
|
|
|
"更多"
|
2026-04-22 05:57:06 +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()
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
{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()
|
2026-04-22 05:57:06 +08:00
|
|
|
|
} 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);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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();
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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" }
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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"
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
Err(err) => {
|
2026-04-30 21:50:40 +08:00
|
|
|
|
set_command_feedback.set(err);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
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>
|
2026-04-22 05:57:06 +08:00
|
|
|
|
</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,
|
|
|
|
|
|
);
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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>
|
2026-04-22 05:57:06 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 05:57:06 +08:00
|
|
|
|
{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 {
|
2026-04-22 05:57:06 +08:00
|
|
|
|
max_height: 360.0,
|
|
|
|
|
|
open_upward: false,
|
2026-04-30 21:50:40 +08:00
|
|
|
|
top: 80.0,
|
|
|
|
|
|
left: 80.0,
|
2026-04-22 05:57:06 +08:00
|
|
|
|
});
|
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" };
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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)
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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="请输入指令"
|
|
|
|
|
|
/>
|
2026-04-22 05:57:06 +08:00
|
|
|
|
<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! {
|
|
|
|
|
|
<>
|
2026-04-22 05:57:06 +08:00
|
|
|
|
<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"
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
>
|
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>
|
2026-04-22 05:57:06 +08:00
|
|
|
|
</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();
|
2026-05-24 23:56:25 +08:00
|
|
|
|
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);
|
2026-05-24 23:56:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
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()
|
2026-05-11 08:27:52 +08:00
|
|
|
|
.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+-",
|
2026-05-11 12:27:40 +08:00
|
|
|
|
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-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
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-22 05:57:06 +08:00
|
|
|
|
}
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
</>
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}.into_any()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
().into_any()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}.into_any()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
().into_any()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
{move || {
|
|
|
|
|
|
if slash_open.get() {
|
|
|
|
|
|
view! {
|
2026-05-20 17:47:09 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="slash-menu"
|
|
|
|
|
|
data-testid="mnote-leptos-tiptap-slash-menu"
|
|
|
|
|
|
style=slash_menu_anchor_style()
|
|
|
|
|
|
>
|
2026-04-22 05:57:06 +08:00
|
|
|
|
<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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
{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;
|
2026-04-22 05:57:06 +08:00
|
|
|
|
let testid = format!("slash-item-{}", action.id);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let slash_change_event_target = slash_change_event_target.clone();
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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>
|
|
|
|
|
|
</>
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}).collect_view()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
.into_any()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
().into_any()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
<TiptapEditor
|
2026-05-08 23:15:00 +08:00
|
|
|
|
id=editor_instance_id.clone()
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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();
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
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();
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-04-22 05:57:06 +08:00
|
|
|
|
);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-22 05:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
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()/> }
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|