refactor: split tiptap block handle menu view

This commit is contained in:
lix-2026
2026-05-26 04:27:35 +08:00
parent b91bad5f14
commit 57da598d74
4 changed files with 704 additions and 526 deletions
@@ -250,13 +250,13 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tree -- --
- [x] E3. `runtime_bridge.rs`:迁出 `dispatch_runtime_event``dispatch_*_to_target`、host command listener install。
- [x] E4. `block_transform.rs`:迁出 `run_block_turn_into_action``top_level_block_matches_action`、page/block transform helpers。
- [x] E5. `slash_menu_view.rs`:迁出 slash menu Leptos view 和 click handling。
- [ ] E6. `block_handle_menu_view.rs`:迁出 block handle menu 和 submenu view,作为最后一刀。
- [x] E6. `block_handle_menu_view.rs`:迁出 block handle menu 和 submenu view,作为最后一刀。
验收标准:
- [x] `lib.rs` 少于 5,000 行。
- [ ] `block_handle_menu_view.rs` 可以超过 1,500 行,但必须有清晰入参 struct,避免二十多个 signal 直接散落。
- [ ] 不改变 Tiptap command 行为,不新增编辑器功能。
- [x] `block_handle_menu_view.rs` 可以超过 1,500 行,但必须有清晰入参 struct,避免二十多个 signal 直接散落。
- [x] 不改变 Tiptap command 行为,不新增编辑器功能。
验收命令:
@@ -0,0 +1,668 @@
use crate::{
current_page_anchor_url, editor_block_by_id, request_ai_edit_bridge,
write_mnote_text_to_clipboard,
};
use crate::editor_runtime::{
block_hover_state::{BlockMenuLayout, HoveredBlockState},
block_transform::{
apply_text_align, run_block_turn_into_action, run_block_turn_into_folded_heading_action,
run_block_turn_into_page_action, top_level_block_is_page, top_level_block_matches_action,
top_level_block_range,
},
command_sync::{read_editor_snapshot, sync_persisted_editor_command},
dom_events::trap_scroll_inside_menu,
dom_selection::runtime_block_id_from_index,
editor_focus::schedule_editor_focus,
persistence::{persist_document_state, persisted_document_identity, PersistedDocumentIdentity},
slash_actions::{SlashActionKind, SLASH_ACTIONS},
};
use leptos::prelude::*;
use leptos_tiptap::{TiptapEditorHandle, TiptapSelectionState, TiptapTextAlign};
use serde_json::Value;
use wasm_bindgen_futures::spawn_local;
use web_sys::{MouseEvent, WheelEvent};
#[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: "折叠小标题",
},
];
pub(crate) struct BlockHandleMenuViewProps {
pub(crate) editor: TiptapEditorHandle,
pub(crate) block: HoveredBlockState,
pub(crate) duplicate_block_label: String,
pub(crate) delete_block_label: String,
pub(crate) menu_layout: BlockMenuLayout,
pub(crate) signals: BlockHandleMenuSignals,
}
#[derive(Clone, Copy)]
pub(crate) struct BlockHandleMenuSignals {
pub(crate) document_id: ReadSignal<Option<String>>,
pub(crate) workspace_id: ReadSignal<Option<String>>,
pub(crate) title: ReadSignal<String>,
pub(crate) selection_state: ReadSignal<TiptapSelectionState>,
pub(crate) block_turn_into_open: ReadSignal<bool>,
pub(crate) block_folded_title_open: ReadSignal<bool>,
pub(crate) set_ai_bridge_state: WriteSignal<String>,
pub(crate) set_ai_bridge_message: WriteSignal<String>,
pub(crate) set_dirty_count: WriteSignal<u32>,
pub(crate) set_html_output: WriteSignal<String>,
pub(crate) set_document_json: WriteSignal<Value>,
pub(crate) set_json_output: WriteSignal<String>,
pub(crate) set_command_feedback: WriteSignal<String>,
pub(crate) set_block_menu_open: WriteSignal<bool>,
pub(crate) set_block_menu_anchor: WriteSignal<Option<HoveredBlockState>>,
pub(crate) set_block_turn_into_open: WriteSignal<bool>,
pub(crate) set_block_folded_title_open: WriteSignal<bool>,
pub(crate) set_hovered_block: WriteSignal<Option<HoveredBlockState>>,
}
fn runtime_persisted_identity(
document_id: ReadSignal<Option<String>>,
workspace_id: ReadSignal<Option<String>>,
) -> PersistedDocumentIdentity {
persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked())
}
fn persist_block_transform_result(
editor: TiptapEditorHandle,
signals: BlockHandleMenuSignals,
) -> (String, Value, String) {
let (html, snapshot, json_text) = read_editor_snapshot(editor);
signals.set_dirty_count.update(|count| *count += 1);
signals.set_html_output.set(html.clone());
signals.set_document_json.set(snapshot.clone());
signals.set_json_output.set(json_text.clone());
(html, snapshot, json_text)
}
fn close_block_transform_menus(signals: BlockHandleMenuSignals) {
signals.set_block_menu_open.set(false);
signals.set_block_menu_anchor.set(None);
signals.set_block_turn_into_open.set(false);
signals.set_block_folded_title_open.set(false);
}
fn handle_turn_into_action_click(
editor: TiptapEditorHandle,
block_index: usize,
kind: SlashActionKind,
label: &'static str,
signals: BlockHandleMenuSignals,
) {
match run_block_turn_into_action(editor, block_index, kind) {
Ok(message) => {
close_block_transform_menus(signals);
let (html, snapshot, _) = persist_block_transform_result(editor, signals);
match persist_document_state(
&runtime_persisted_identity(signals.document_id, signals.workspace_id),
&signals.title.get_untracked(),
&snapshot,
Some(html),
) {
Ok(()) => signals.set_command_feedback.set(format!(
"{}:块 -> {},并已写入本地草稿",
message, label,
)),
Err(err) => signals.set_command_feedback.set(format!(
"{}:块 -> {},但本地保存失败:{}",
message, label, err,
)),
}
}
Err(err) => signals.set_command_feedback.set(err),
}
}
fn handle_folded_heading_click(
editor: TiptapEditorHandle,
block_index: usize,
action: FoldedHeadingAction,
signals: BlockHandleMenuSignals,
) {
match run_block_turn_into_folded_heading_action(editor, block_index, action.level) {
Ok(message) => {
close_block_transform_menus(signals);
let (html, snapshot, _) = persist_block_transform_result(editor, signals);
match persist_document_state(
&runtime_persisted_identity(signals.document_id, signals.workspace_id),
&signals.title.get_untracked(),
&snapshot,
Some(html),
) {
Ok(()) => signals
.set_command_feedback
.set(format!("{},并已写入本地草稿", message)),
Err(err) => signals
.set_command_feedback
.set(format!("{},但本地保存失败:{}", message, err)),
}
}
Err(err) => signals.set_command_feedback.set(err),
}
}
fn block_transform_shortcut(
editor: TiptapEditorHandle,
block_index: usize,
kind: SlashActionKind,
) -> &'static str {
if top_level_block_matches_action(editor, block_index, kind) {
return "";
}
match kind {
SlashActionKind::Paragraph => "Ctrl+Alt+0",
SlashActionKind::Todo => "Ctrl+Shift+5",
SlashActionKind::Heading1 => "Ctrl+Shift+1",
SlashActionKind::Heading2 => "Ctrl+Shift+2",
SlashActionKind::Heading3 => "Ctrl+Shift+3",
SlashActionKind::BulletList => "Ctrl+Shift+6",
SlashActionKind::OrderedList => "Ctrl+Shift+7",
SlashActionKind::CodeBlock => "Ctrl+Alt+-",
SlashActionKind::Mindmap => "/dt",
_ => "",
}
}
fn block_transform_submenu_view(
editor: TiptapEditorHandle,
block_index: usize,
signals: BlockHandleMenuSignals,
) -> impl IntoView {
let current_block_is_page = top_level_block_is_page(editor, block_index);
let page_current_marker = if current_block_is_page {
""
} else {
"Ctrl+Shift+9"
};
view! {
<div class="block-drag-submenu" data-testid="block-transform-submenu" data-e30-testid="block-turn-into-menu">
{SLASH_ACTIONS
.iter()
.filter(|action| {
!matches!(
action.kind,
SlashActionKind::Divider
| SlashActionKind::SimpleTable
| SlashActionKind::Image
| SlashActionKind::UploadAttachment
| SlashActionKind::Toc
| SlashActionKind::AiAssistant
| SlashActionKind::AiWrite
| SlashActionKind::ContinueWriting
| SlashActionKind::Summarize
| SlashActionKind::MoreAi
)
})
.map(|action| {
let kind = action.kind;
let label = action.label;
let icon = action.icon;
let is_current = top_level_block_matches_action(editor, block_index, kind);
let shortcut = block_transform_shortcut(editor, block_index, kind);
let testid = format!("block-transform-item-{}", action.id);
view! {
<button
class="block-drag-menu-item"
data-testid=testid
data-current=is_current.to_string()
on:click=move |event: MouseEvent| {
event.stop_propagation();
handle_turn_into_action_click(editor, block_index, kind, label, signals);
}
>
<span class="block-drag-menu-icon">{icon}</span>
<span>{label}</span>
<span class="block-drag-menu-shortcut">{shortcut}</span>
</button>
}
})
.collect_view()}
<button
class="block-drag-menu-item"
data-testid="block-transform-item-page"
data-current=current_block_is_page.to_string()
on:click=move |event: MouseEvent| {
event.stop_propagation();
run_block_turn_into_page_action(
editor,
block_index,
signals.document_id,
signals.workspace_id,
signals.title,
signals.set_dirty_count,
signals.set_html_output,
signals.set_document_json,
signals.set_json_output,
signals.set_command_feedback,
signals.set_block_menu_open,
signals.set_block_menu_anchor,
signals.set_block_turn_into_open,
);
}
>
<span class="block-drag-menu-icon">""</span>
<span>"页面"</span>
<span class="block-drag-menu-shortcut">{page_current_marker}</span>
</button>
<button class="block-drag-menu-item" data-testid="block-transform-item-folded-list" disabled=true>
<span class="block-drag-menu-icon">""</span>
<span>"折叠列表"</span>
<span class="block-drag-menu-shortcut">"Ctrl+Shift+8"</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-transform-item-folded-title"
on:mouseenter=move |_| {
signals.set_block_folded_title_open.set(true);
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
signals.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 signals.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();
handle_folded_heading_click(editor, block_index, action, signals);
}
>
<span class="block-drag-menu-icon">{action.icon}</span>
<span>{action.label}</span>
</button>
}
})
.collect_view()}
</div>
}.into_any()
} else {
().into_any()
}
}}
</div>
}
}
pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl IntoView {
let BlockHandleMenuViewProps {
editor,
block,
duplicate_block_label,
delete_block_label,
menu_layout,
signals,
} = props;
let block_index = block.index;
let menu_top = format!("{}px", menu_layout.top);
let menu_bottom = "auto";
view! {
<div
class="block-drag-menu"
data-testid="block-drag-menu"
style:top=menu_top
style:bottom=menu_bottom
style:left=format!("{}px", menu_layout.left)
style:max-height=format!("{}px", menu_layout.max_height)
on:mousedown=move |event: MouseEvent| {
event.stop_propagation();
}
on:mouseup=move |event: MouseEvent| {
event.stop_propagation();
}
on:mousemove=move |event: MouseEvent| {
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
on:wheel=move |event: WheelEvent| {
trap_scroll_inside_menu(&event);
}
>
<input
class="block-drag-command-input"
data-testid="block-drag-command-input"
readonly=true
placeholder="请输入指令"
/>
<div class="block-drag-menu-actions">
<div class="block-drag-menu-section">
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-ai"
on:click=move |event: MouseEvent| {
event.stop_propagation();
request_ai_edit_bridge(
editor,
"ask_ai",
signals.document_id,
signals.workspace_id,
Some(block_index),
runtime_block_id_from_index(block_index),
signals.selection_state,
signals.set_ai_bridge_state,
signals.set_ai_bridge_message,
signals.set_dirty_count,
signals.set_html_output,
signals.set_document_json,
signals.set_json_output,
signals.title,
signals.set_command_feedback,
);
}
>
<span class="block-drag-menu-icon">""</span>
<span>"AI 助理"</span>
<span class="block-drag-menu-shortcut">"Ctrl+J"</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-turn-into"
on:mouseenter=move |_| {
signals.set_block_turn_into_open.set(true);
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
signals.set_block_turn_into_open.set(true);
}
>
<span class="block-drag-menu-icon">""</span>
<span>"转换为"</span>
<span class="block-drag-menu-arrow">""</span>
</button>
</div>
<div class="block-drag-menu-divider"></div>
<div class="block-drag-menu-section">
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-duplicate"
on:click=move |event: MouseEvent| {
event.stop_propagation();
match crate::editor_runtime::history_safe_commands::duplicate_top_level_block_with_history(&editor, block_index) {
Ok(()) => {
sync_persisted_editor_command(
editor,
&runtime_persisted_identity(signals.document_id, signals.workspace_id),
signals.set_dirty_count,
signals.set_html_output,
signals.set_document_json,
signals.set_json_output,
signals.title,
signals.set_command_feedback,
format!("已复制 {}", duplicate_block_label.clone()),
);
signals.set_block_menu_open.set(false);
signals.set_block_menu_anchor.set(None);
}
Err(err) => signals.set_command_feedback.set(err),
}
}
>
<span class="block-drag-menu-icon">""</span>
<span>"拷贝副本"</span>
<span class="block-drag-menu-shortcut">"Ctrl+D"</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-delete"
on:mousedown=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
match crate::editor_runtime::history_safe_commands::delete_top_level_block_with_history(&editor, block_index) {
Ok(()) => {
sync_persisted_editor_command(
editor,
&runtime_persisted_identity(signals.document_id, signals.workspace_id),
signals.set_dirty_count,
signals.set_html_output,
signals.set_document_json,
signals.set_json_output,
signals.title,
signals.set_command_feedback,
format!("已删除 {}", delete_block_label.clone()),
);
signals.set_block_menu_open.set(false);
signals.set_block_menu_anchor.set(None);
signals.set_hovered_block.set(None);
schedule_editor_focus(editor);
}
Err(err) => signals.set_command_feedback.set(err),
}
}
>
<span class="block-drag-menu-icon">""</span>
<span>"删除"</span>
<span class="block-drag-menu-shortcut">"Del"</span>
</button>
</div>
<div class="block-drag-menu-divider"></div>
<div class="block-drag-menu-section">
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-copy-link"
on:click=move |event: MouseEvent| {
event.stop_propagation();
let Some(block_id) = runtime_block_id_from_index(block_index) else {
signals
.set_command_feedback
.set("当前块缺少 Rust block id,无法复制锚点链接".to_string());
return;
};
let Some(anchor_url) = current_page_anchor_url(&block_id) else {
signals
.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) => {
signals.set_command_feedback.set(format!("已复制块链接:{}", block_id));
}
_ => {
signals
.set_command_feedback
.set(format!("块链接已生成但剪贴板写入失败:{}", anchor_url));
}
}
});
}
>
<span class="block-drag-menu-icon">"🔗"</span>
<span>"复制链接"</span>
<span class="block-drag-menu-arrow">""</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-move"
on:click=move |event: MouseEvent| {
event.stop_propagation();
signals
.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();
signals
.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();
signals.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 |_| {
signals
.set_command_feedback
.set("颜色子菜单后续按 Wolai 继续补齐".to_string());
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
signals
.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);
signals.set_dirty_count.update(|count| *count += 1);
signals.set_html_output.set(html.clone());
signals.set_document_json.set(snapshot.clone());
signals.set_json_output.set(json_text);
persist_document_state(
&runtime_persisted_identity(signals.document_id, signals.workspace_id),
&signals.title.get_untracked(),
&snapshot,
Some(html),
)
.map_err(|err| format!("文字居中已执行,但本地保存失败:{err}"))?;
Ok(())
})();
match result {
Ok(()) => signals
.set_command_feedback
.set("已切到居中,并已写入本地草稿".to_string()),
Err(err) => signals.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 signals.block_turn_into_open.get() {
block_transform_submenu_view(editor, block_index, signals).into_any()
} else {
().into_any()
}
}}
</div>
</div>
}
}
@@ -2,6 +2,7 @@ pub(crate) mod attachment_links;
pub(crate) mod attachment_upload;
pub(crate) mod block_dnd;
pub(crate) mod block_hover_state;
pub(crate) mod block_handle_menu_view;
pub(crate) mod block_menu_document;
pub(crate) mod block_menu_overlay;
pub(crate) mod block_transform;
+32 -523
View File
@@ -11,7 +11,6 @@ use serde::Deserialize;
use serde_json::{json, Value};
use std::fmt::Display;
use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
use wasm_bindgen_futures::spawn_local;
use web_sys::{
window, CustomEvent, DragEvent, Element, Event, EventTarget, HtmlElement, MouseEvent,
WheelEvent,
@@ -26,13 +25,14 @@ use editor_runtime::block_dnd::{
use editor_runtime::block_hover_state::{
BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState,
};
use editor_runtime::block_handle_menu_view::{
block_handle_menu_view, BlockHandleMenuSignals, BlockHandleMenuViewProps,
};
use editor_runtime::block_menu_overlay;
use editor_runtime::block_transform::{
apply_block_delta_to_json, apply_highlight_color, apply_image_align, apply_text_align,
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,
prosemirror_node_size, run_slash_action, top_level_block_boundary_position,
};
use editor_runtime::bridge_dispatch::HostCommandKind;
use editor_runtime::bridge_events::{
@@ -40,9 +40,7 @@ use editor_runtime::bridge_events::{
HostCommandPayload, HostStatusPayload, ReadyPayload, StatePayload, BLOCK_DELTA_EVENT,
COMMAND_EVENT, HEIGHT_EVENT, PROTOCOL, RUNTIME_NAME,
};
use editor_runtime::command_sync::{
read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command,
};
use editor_runtime::command_sync::{sync_editor_outputs, sync_persisted_editor_command};
use editor_runtime::content_layout::{content_column_left, content_text_left};
use editor_runtime::dom_events::{
event_target_matches_selector, target_element, trap_scroll_inside_menu,
@@ -339,41 +337,6 @@ const TOOLBAR_ALIGN_OPTIONS: [ToolbarAlignOption; 4] = [
},
];
#[derive(Clone, Copy)]
struct FoldedHeadingAction {
level: u8,
id: &'static str,
icon: &'static str,
label: &'static str,
}
const FOLDED_HEADING_ACTIONS: [FoldedHeadingAction; 4] = [
FoldedHeadingAction {
level: 1,
id: "1",
icon: "H1",
label: "",
},
FoldedHeadingAction {
level: 2,
id: "2",
icon: "H2",
label: "",
},
FoldedHeadingAction {
level: 3,
id: "3",
icon: "H3",
label: "",
},
FoldedHeadingAction {
level: 4,
id: "4",
icon: "H4",
label: "",
},
];
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
@@ -3378,8 +3341,6 @@ fn App(mount_options: MountOptions) -> impl IntoView {
.map(|current| current.index == block_index)
.unwrap_or(false)
{
let current_duplicate_label = duplicate_block_label.clone();
let current_delete_label = delete_block_label.clone();
let menu_layout = editor_stage_element().and_then(|stage| {
let rect = stage.get_bounding_client_rect();
block_menu_overlay::block_menu_layout(&block, rect.width(), rect.height(), rect.top(), rect.left())
@@ -3389,485 +3350,33 @@ fn App(mount_options: MountOptions) -> impl IntoView {
top: 80.0,
left: 80.0,
});
let menu_top = format!("{}px", menu_layout.top);
let menu_bottom = "auto";
let current_block_is_page = top_level_block_is_page(editor, block_index);
let page_current_marker = if current_block_is_page { "" } else { "Ctrl+Shift+9" };
view! {
<div
class="block-drag-menu"
data-testid="block-drag-menu"
style:top=menu_top
style:bottom=menu_bottom
style:left=format!("{}px", menu_layout.left)
style:max-height=format!("{}px", menu_layout.max_height)
on:mousedown=move |event: MouseEvent| {
event.stop_propagation();
}
on:mouseup=move |event: MouseEvent| {
event.stop_propagation();
}
on:mousemove=move |event: MouseEvent| {
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
on:wheel=move |event: WheelEvent| {
trap_scroll_inside_menu(&event);
}
>
<input
class="block-drag-command-input"
data-testid="block-drag-command-input"
readonly=true
placeholder="请输入指令"
/>
<div class="block-drag-menu-actions">
{move || {
let duplicate_feedback_label = current_duplicate_label.clone();
let delete_feedback_label = current_delete_label.clone();
view! {
<>
<div class="block-drag-menu-section">
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-ai"
on:click=move |event: MouseEvent| {
event.stop_propagation();
request_ai_edit_bridge(
editor,
"ask_ai",
document_id,
workspace_id,
Some(block_index),
runtime_block_id_from_index(block_index),
selection_state,
set_ai_bridge_state,
set_ai_bridge_message,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
);
}
>
<span class="block-drag-menu-icon">""</span>
<span>"AI 助理"</span>
<span class="block-drag-menu-shortcut">"Ctrl+J"</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-turn-into"
on:mouseenter=move |_| {
set_block_turn_into_open.set(true);
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_block_turn_into_open.set(true);
}
>
<span class="block-drag-menu-icon">""</span>
<span>"转换为"</span>
<span class="block-drag-menu-arrow">""</span>
</button>
</div>
<div class="block-drag-menu-divider"></div>
<div class="block-drag-menu-section">
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-duplicate"
on:click=move |event: MouseEvent| {
event.stop_propagation();
match editor_runtime::history_safe_commands::duplicate_top_level_block_with_history(&editor, block_index) {
Ok(()) => {
sync_persisted_editor_command(
editor,
&runtime_persisted_identity(document_id, workspace_id),
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
format!("已复制 {}", duplicate_feedback_label.clone()),
);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
}
Err(err) => set_command_feedback.set(err),
}
}
>
<span class="block-drag-menu-icon">""</span>
<span>"拷贝副本"</span>
<span class="block-drag-menu-shortcut">"Ctrl+D"</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-delete"
on:mousedown=move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
match editor_runtime::history_safe_commands::delete_top_level_block_with_history(&editor, block_index) {
Ok(()) => {
sync_persisted_editor_command(
editor,
&runtime_persisted_identity(document_id, workspace_id),
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
format!("已删除 {}", delete_feedback_label.clone()),
);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_hovered_block.set(None);
schedule_editor_focus(editor);
}
Err(err) => set_command_feedback.set(err),
}
}
>
<span class="block-drag-menu-icon">""</span>
<span>"删除"</span>
<span class="block-drag-menu-shortcut">"Del"</span>
</button>
</div>
<div class="block-drag-menu-divider"></div>
<div class="block-drag-menu-section">
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-copy-link"
on:click=move |event: MouseEvent| {
event.stop_propagation();
let Some(block_id) = runtime_block_id_from_index(block_index) else {
set_command_feedback.set("当前块缺少 Rust block id,无法复制锚点链接".to_string());
return;
};
let Some(anchor_url) = current_page_anchor_url(&block_id) else {
set_command_feedback.set("当前页面 URL 不可用,无法复制锚点链接".to_string());
return;
};
if let Some(block_element) = editor_block_by_id(&block_id) {
let _ = block_element.scroll_into_view_with_bool(true);
}
spawn_local(async move {
match write_mnote_text_to_clipboard(anchor_url.clone()).await {
Ok(value) if value.as_bool().unwrap_or(false) => {
set_command_feedback.set(format!("已复制块链接:{}", block_id));
}
_ => {
set_command_feedback.set(format!("块链接已生成但剪贴板写入失败:{}", anchor_url));
}
}
});
}
>
<span class="block-drag-menu-icon">"🔗"</span>
<span>"复制链接"</span>
<span class="block-drag-menu-arrow">""</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-move"
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_command_feedback.set("移动/嵌入流程后续接 Page Aggregate 命令".to_string());
}
>
<span class="block-drag-menu-icon">""</span>
<span>"移动/嵌入到..."</span>
<span class="block-drag-menu-shortcut">"Alt+Shift+M/G"</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-history"
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_command_feedback.set("块历史入口已记录,恢复流程后续接版本链路".to_string());
}
>
<span class="block-drag-menu-icon">""</span>
<span>"块历史..."</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-comment"
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_command_feedback.set("评论入口已打开".to_string());
}
>
<span class="block-drag-menu-icon">""</span>
<span>"评论"</span>
<span class="block-drag-menu-shortcut">"Ctrl+Alt+M"</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-color"
on:mouseenter=move |_| {
set_command_feedback.set("颜色子菜单后续按 Wolai 继续补齐".to_string());
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_command_feedback.set("颜色子菜单后续按 Wolai 继续补齐".to_string());
}
>
<span class="block-drag-menu-icon">""</span>
<span>"颜色"</span>
<span class="block-drag-menu-arrow">""</span>
</button>
<button
class="block-drag-menu-item"
data-testid="block-drag-menu-item-align-center"
on:click=move |event: MouseEvent| {
event.stop_propagation();
let result = (|| -> Result<(), String> {
let document = editor
.get_json()
.map_err(|err| format!("读取当前 JSON 失败:{err}"))?;
let range = top_level_block_range(&document, block_index)
.ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?;
editor
.set_text_selection(range)
.map_err(|err| format!("选中当前块失败:{err}"))?;
apply_text_align(editor, TiptapTextAlign::Center)?;
let (html, snapshot, json_text) = read_editor_snapshot(editor);
set_dirty_count.update(|count| *count += 1);
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
&snapshot,
Some(html),
)
.map_err(|err| format!("文字居中已执行,但本地保存失败:{err}"))?;
Ok(())
})();
match result {
Ok(()) => set_command_feedback.set("已切到居中,并已写入本地草稿".to_string()),
Err(err) => set_command_feedback.set(err),
}
}
>
<span class="block-drag-menu-icon">""</span>
<span>"文字居中"</span>
<span class="block-drag-menu-switch"></span>
</button>
<button class="block-drag-menu-item" data-testid="block-drag-menu-item-translate" disabled=true>
<span class="block-drag-menu-icon">""</span>
<span>"文字翻译"</span>
<span class="block-drag-menu-arrow">""</span>
</button>
<button class="block-drag-menu-item" data-testid="block-drag-menu-item-poster" disabled=true>
<span class="block-drag-menu-icon">""</span>
<span>"生成海报"</span>
</button>
</div>
<div class="block-drag-menu-footer" data-testid="block-drag-menu-footer">
<span>"anonymous"</span>
<span>"最后编辑于 刚刚"</span>
</div>
{move || {
if block_turn_into_open.get() {
view! {
<div class="block-drag-submenu" data-testid="block-transform-submenu" data-e30-testid="block-turn-into-menu">
{SLASH_ACTIONS
.iter()
.filter(|action| !matches!(action.kind, SlashActionKind::Divider | SlashActionKind::SimpleTable | SlashActionKind::Image | SlashActionKind::UploadAttachment | SlashActionKind::Toc | SlashActionKind::AiAssistant | SlashActionKind::AiWrite | SlashActionKind::ContinueWriting | SlashActionKind::Summarize | SlashActionKind::MoreAi))
.map(|action| {
let kind = action.kind;
let label = action.label;
let icon = action.icon;
let is_current = top_level_block_matches_action(editor, block_index, action.kind);
let shortcut = if is_current {
""
} else {
match action.kind {
SlashActionKind::Paragraph => "Ctrl+Alt+0",
SlashActionKind::Todo => "Ctrl+Shift+5",
SlashActionKind::Heading1 => "Ctrl+Shift+1",
SlashActionKind::Heading2 => "Ctrl+Shift+2",
SlashActionKind::Heading3 => "Ctrl+Shift+3",
SlashActionKind::BulletList => "Ctrl+Shift+6",
SlashActionKind::OrderedList => "Ctrl+Shift+7",
SlashActionKind::CodeBlock => "Ctrl+Alt+-",
SlashActionKind::Mindmap => "/dt",
_ => "",
}
};
let testid = format!("block-transform-item-{}", action.id);
let action_menu_label = "".to_string();
view! {
<button
class="block-drag-menu-item"
data-testid=testid
data-current=is_current.to_string()
on:click=move |event: MouseEvent| {
event.stop_propagation();
match run_block_turn_into_action(editor, block_index, kind) {
Ok(message) => {
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_block_turn_into_open.set(false);
set_block_folded_title_open.set(false);
let (html, snapshot, json_text) = read_editor_snapshot(editor);
set_dirty_count.update(|count| *count += 1);
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
&snapshot,
Some(html),
) {
Ok(()) => set_command_feedback.set(format!(
"{}{} -> {},并已写入本地草稿",
message,
action_menu_label.clone(),
label,
)),
Err(err) => set_command_feedback.set(format!(
"{}{} -> {},但本地保存失败:{}",
message,
action_menu_label.clone(),
label,
err,
)),
}
}
Err(err) => set_command_feedback.set(err),
}
}
>
<span class="block-drag-menu-icon">{icon}</span>
<span>{label}</span>
<span class="block-drag-menu-shortcut">{shortcut}</span>
</button>
}
})
.collect_view()}
<button
class="block-drag-menu-item"
data-testid="block-transform-item-page"
data-current=current_block_is_page.to_string()
on:click=move |event: MouseEvent| {
event.stop_propagation();
run_block_turn_into_page_action(
editor,
block_index,
document_id,
workspace_id,
title,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
set_command_feedback,
set_block_menu_open,
set_block_menu_anchor,
set_block_turn_into_open,
);
}
>
<span class="block-drag-menu-icon">""</span>
<span>"页面"</span>
<span class="block-drag-menu-shortcut">{page_current_marker}</span>
</button>
<button class="block-drag-menu-item" data-testid="block-transform-item-folded-list" disabled=true><span class="block-drag-menu-icon">""</span><span>"折叠列表"</span><span class="block-drag-menu-shortcut">"Ctrl+Shift+8"</span></button>
<button
class="block-drag-menu-item"
data-testid="block-transform-item-folded-title"
on:mouseenter=move |_| {
set_block_folded_title_open.set(true);
}
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_block_folded_title_open.set(true);
}
>
<span class="block-drag-menu-icon">"H1"</span>
<span>"折叠标题"</span>
<span class="block-drag-menu-arrow">""</span>
</button>
<button class="block-drag-menu-item" data-testid="block-transform-item-folded-todo" disabled=true><span class="block-drag-menu-icon">""</span><span>"折叠待办"</span><span class="block-drag-menu-arrow">""</span></button>
<button class="block-drag-menu-item" data-testid="block-transform-item-emphasis" disabled=true><span class="block-drag-menu-icon">"Aa"</span><span>"着重文字"</span></button>
<button class="block-drag-menu-item" data-testid="block-transform-item-math" disabled=true><span class="block-drag-menu-icon">"f(x)"</span><span>"数学公式"</span><span class="block-drag-menu-shortcut">"Ctrl+Alt++"</span></button>
{move || {
if block_folded_title_open.get() {
view! {
<div class="block-drag-tertiary-submenu" data-testid="block-transform-folded-title-submenu">
{FOLDED_HEADING_ACTIONS
.iter()
.map(|folded| {
let action = *folded;
let testid = format!("block-transform-folded-heading-{}", action.id);
view! {
<button
class="block-drag-menu-item"
data-testid=testid
on:click=move |event: MouseEvent| {
event.stop_propagation();
match run_block_turn_into_folded_heading_action(editor, block_index, action.level) {
Ok(message) => {
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_block_turn_into_open.set(false);
set_block_folded_title_open.set(false);
let (html, snapshot, json_text) = read_editor_snapshot(editor);
set_dirty_count.update(|count| *count += 1);
set_html_output.set(html.clone());
set_document_json.set(snapshot.clone());
set_json_output.set(json_text);
match persist_document_state(
&runtime_persisted_identity(document_id, workspace_id),
&title.get_untracked(),
&snapshot,
Some(html),
) {
Ok(()) => set_command_feedback.set(format!("{},并已写入本地草稿", message)),
Err(err) => set_command_feedback.set(format!("{},但本地保存失败:{}", message, err)),
}
}
Err(err) => set_command_feedback.set(err),
}
}
>
<span class="block-drag-menu-icon">{action.icon}</span>
<span>{action.label}</span>
</button>
}
})
.collect_view()}
</div>
}.into_any()
} else {
().into_any()
}
}}
</div>
}.into_any()
} else {
().into_any()
}
}}
</>
}
}}
</div>
</div>
}.into_any()
block_handle_menu_view(BlockHandleMenuViewProps {
editor,
block: block.clone(),
duplicate_block_label: duplicate_block_label.clone(),
delete_block_label: delete_block_label.clone(),
menu_layout,
signals: BlockHandleMenuSignals {
document_id,
workspace_id,
title,
selection_state,
block_turn_into_open,
block_folded_title_open,
set_ai_bridge_state,
set_ai_bridge_message,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
set_command_feedback,
set_block_menu_open,
set_block_menu_anchor,
set_block_turn_into_open,
set_block_folded_title_open,
set_hovered_block,
},
}).into_any()
} else {
().into_any()
}