From 396c64e09e9e7864f798a0b0acb8e95bfd7217f1 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 26 May 2026 03:21:11 +0800 Subject: [PATCH] refactor: split tiptap block transform helpers --- ...ime-module-maintainability-checklist-v1.md | 2 +- .../src/editor_runtime/block_transform.rs | 268 ++++++++++++++++++ rust/spikes/leptos-tiptap-spike/src/lib.rs | 257 +---------------- 3 files changed, 275 insertions(+), 252 deletions(-) diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index 07295eb9..c253c236 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -252,7 +252,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tree -- -- 验收标准: -- [ ] `lib.rs` 少于 5,000 行。 +- [x] `lib.rs` 少于 5,000 行。 - [ ] `block_handle_menu_view.rs` 可以超过 1,500 行,但必须有清晰入参 struct,避免二十多个 signal 直接散落。 - [ ] 不改变 Tiptap command 行为,不新增编辑器功能。 diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs index 8e71728c..c281e6a7 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs @@ -1,5 +1,273 @@ +use leptos_tiptap::{ + TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditorHandle, + TiptapHeadingLevel, TiptapHighlightAttributes, TiptapNodeName, TiptapSchemaTarget, + TiptapTextAlign, TiptapTocNodeAttrs, +}; use serde_json::{json, Value}; +use crate::editor_runtime::attachment_upload::dispatch_editor_upload_request; +use crate::editor_runtime::block_menu_document::mindmap_paragraph_node; +use crate::editor_runtime::slash_actions::SlashActionKind; + +pub(crate) fn apply_text_color( + editor: TiptapEditorHandle, + color: &str, +) -> Result<&'static str, String> { + editor + .set_color(TiptapColorAttributes { + color: color.to_string(), + }) + .map(|_| "已更新文字颜色") + .map_err(|err| format!("命令执行失败:{err}")) +} + +pub(crate) fn clear_text_color(editor: TiptapEditorHandle) -> Result<&'static str, String> { + editor + .unset_color() + .map(|_| "已清除文字颜色") + .map_err(|err| format!("命令执行失败:{err}")) +} + +pub(crate) fn apply_highlight_color( + editor: TiptapEditorHandle, + color: Option<&str>, +) -> Result<&'static str, String> { + match color { + Some(color) => editor + .set_highlight(Some(TiptapHighlightAttributes { + color: Some(color.to_string()), + })) + .map(|_| "已更新背景色") + .map_err(|err| format!("命令执行失败:{err}")), + None => editor + .unset_highlight() + .map(|_| "已清除背景色") + .map_err(|err| format!("命令执行失败:{err}")), + } +} + +pub(crate) fn apply_text_align( + editor: TiptapEditorHandle, + alignment: TiptapTextAlign, +) -> Result<&'static str, String> { + let message = match alignment { + TiptapTextAlign::Left => "已切到左对齐", + TiptapTextAlign::Center => "已切到居中", + TiptapTextAlign::Right => "已切到右对齐", + TiptapTextAlign::Justify => "已切到两端对齐", + }; + editor + .set_text_align(alignment) + .map(|_| message) + .map_err(|err| format!("命令执行失败:{err}")) +} + +pub(crate) fn apply_image_align( + editor: TiptapEditorHandle, + align: &'static str, +) -> Result<&'static str, String> { + let _ = editor.focus(); + let mut attrs = leptos_tiptap::TiptapAttributes::new(); + attrs.insert("data-align", align); + editor + .update_attributes(TiptapSchemaTarget::Node(TiptapNodeName::Image), attrs) + .map(|_| "已更新图片对齐") + .map_err(|err| format!("命令执行失败:{err}")) +} + +pub(crate) fn run_slash_action( + editor: TiptapEditorHandle, + action: SlashActionKind, +) -> Result<&'static str, String> { + let result = match action { + SlashActionKind::AiAssistant + | SlashActionKind::AiWrite + | SlashActionKind::ContinueWriting + | SlashActionKind::Summarize + | SlashActionKind::MoreAi => return Ok("AI 命令入口已打开"), + SlashActionKind::Paragraph => editor.set_paragraph(), + SlashActionKind::Heading1 => editor.toggle_heading(TiptapHeadingLevel::H1), + SlashActionKind::Heading2 => editor.toggle_heading(TiptapHeadingLevel::H2), + SlashActionKind::Heading3 => editor.toggle_heading(TiptapHeadingLevel::H3), + SlashActionKind::Heading4 => editor.toggle_heading(TiptapHeadingLevel::H4), + SlashActionKind::BulletList => editor.toggle_bullet_list(), + SlashActionKind::OrderedList => editor.toggle_ordered_list(), + SlashActionKind::Todo | SlashActionKind::AdvancedTodo => editor.toggle_task_list(), + SlashActionKind::Quote => editor.toggle_blockquote(), + SlashActionKind::CodeBlock => editor.toggle_code_block(Some(TiptapCodeBlockAttributes { + language: Some("rust".into()), + })), + SlashActionKind::Divider => editor.set_horizontal_rule(), + SlashActionKind::SimpleTable => { + let _ = editor.focus(); + editor.insert_table(4, 3, false) + } + SlashActionKind::Mindmap => { + editor.insert_content(TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)), None) + } + SlashActionKind::Image => { + dispatch_editor_upload_request("image", "image/*")?; + return Ok("已打开图片上传"); + } + SlashActionKind::UploadAttachment => { + dispatch_editor_upload_request("attachment", "")?; + return Ok("已打开附件上传"); + } + SlashActionKind::Toc => editor.insert_toc_node(TiptapTocNodeAttrs { + top_offset: Some(0), + max_show_count: Some(20), + show_title: Some(true), + }), + }; + + result + .map(|_| match action { + SlashActionKind::AiAssistant + | SlashActionKind::AiWrite + | SlashActionKind::ContinueWriting + | SlashActionKind::Summarize + | SlashActionKind::MoreAi => "AI 命令入口已打开", + SlashActionKind::Paragraph => "已切到文本", + SlashActionKind::Heading1 => "已切到一级标题", + SlashActionKind::Heading2 => "已切到二级标题", + SlashActionKind::Heading3 => "已切到三级标题", + SlashActionKind::Heading4 => "已切到四级标题", + SlashActionKind::BulletList => "已切到无序列表", + SlashActionKind::OrderedList => "已切到有序列表", + SlashActionKind::Todo | SlashActionKind::AdvancedTodo => "已切到待办列表", + SlashActionKind::Quote => "已切到引述文字", + SlashActionKind::CodeBlock => "已切到代码块", + SlashActionKind::Divider => "已插入分割线", + SlashActionKind::SimpleTable => "已插入简单表格", + SlashActionKind::Mindmap => "已插入思维导图", + SlashActionKind::Image => "已打开图片上传", + SlashActionKind::UploadAttachment => "已打开附件上传", + SlashActionKind::Toc => "已插入页面目录", + }) + .map_err(|err| format!("命令执行失败:{err}")) +} + +pub(crate) fn top_level_block_is_page( + editor: TiptapEditorHandle, + block_index: usize, +) -> bool { + editor + .get_json() + .ok() + .and_then(|document| { + document + .get("content") + .and_then(Value::as_array) + .and_then(|content| content.get(block_index).cloned()) + }) + .map(|block| inline_has_page_block_link(&block)) + .unwrap_or(false) +} + +pub(crate) fn top_level_block_matches_action( + editor: TiptapEditorHandle, + block_index: usize, + action: SlashActionKind, +) -> bool { + let Some(block) = editor.get_json().ok().and_then(|document| { + document + .get("content") + .and_then(Value::as_array) + .and_then(|content| content.get(block_index).cloned()) + }) else { + return false; + }; + + let node_type = block + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + match action { + SlashActionKind::Paragraph => { + node_type == "paragraph" && !inline_has_page_block_link(&block) + } + SlashActionKind::Heading1 + | SlashActionKind::Heading2 + | SlashActionKind::Heading3 + | SlashActionKind::Heading4 => { + let expected_level = match action { + SlashActionKind::Heading1 => 1, + SlashActionKind::Heading2 => 2, + SlashActionKind::Heading3 => 3, + SlashActionKind::Heading4 => 4, + _ => 0, + }; + node_type == "heading" + && block + .get("attrs") + .and_then(|attrs| attrs.get("level")) + .and_then(Value::as_i64) + .map(|level| level == expected_level) + .unwrap_or(false) + } + SlashActionKind::BulletList => node_type == "bulletList", + SlashActionKind::OrderedList => node_type == "orderedList", + SlashActionKind::Todo | SlashActionKind::AdvancedTodo => node_type == "taskList", + SlashActionKind::Quote => node_type == "blockquote", + SlashActionKind::CodeBlock => node_type == "codeBlock", + SlashActionKind::Divider => node_type == "horizontalRule", + SlashActionKind::SimpleTable => node_type == "table", + SlashActionKind::Mindmap => { + node_type == "paragraph" + && block + .get("attrs") + .and_then(|attrs| attrs.get("mnoteBlockType")) + .and_then(Value::as_str) + == Some("mindmap") + } + SlashActionKind::Image => node_type == "image", + SlashActionKind::UploadAttachment => false, + SlashActionKind::Toc => node_type == "tocNode", + SlashActionKind::AiAssistant + | SlashActionKind::AiWrite + | SlashActionKind::ContinueWriting + | SlashActionKind::Summarize + | SlashActionKind::MoreAi => false, + } +} + +fn next_mindmap_id() -> String { + format!( + "mindmap-{}", + (js_sys::Math::random() * 1_000_000_000.0).round() as u64 + ) +} + +fn inline_has_page_block_link(node: &Value) -> bool { + if node + .get("marks") + .and_then(Value::as_array) + .map(|marks| { + marks.iter().any(|mark| { + mark.get("type").and_then(Value::as_str) == Some("link") + && mark + .get("attrs") + .and_then(|attrs| attrs.get("class")) + .and_then(Value::as_str) + .map(|class_name| { + class_name + .split_whitespace() + .any(|part| part == "mnote-page-block-link") + }) + .unwrap_or(false) + }) + }) + .unwrap_or(false) + { + return true; + } + + node.get("content") + .and_then(Value::as_array) + .map(|children| children.iter().any(inline_has_page_block_link)) + .unwrap_or(false) +} + /// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。 /// 返回 true 表示树发生了变更。 pub(crate) fn apply_block_delta_to_json(content: &mut Value, operations: &[Value]) -> bool { diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 13e8374f..b1e57a48 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -3,8 +3,7 @@ use leptos::mount::{mount_to, mount_to_body}; use leptos::prelude::*; use leptos_dom::helpers::window_event_listener; use leptos_tiptap::{ - TiptapCodeBlockAttributes, TiptapColorAttributes, TiptapContent, TiptapEditor, - TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel, TiptapHighlightAttributes, + TiptapContent, TiptapEditor, TiptapEditorHandle, TiptapExtension, TiptapHeadingLevel, TiptapInsertContentOptions, TiptapLinkResource, TiptapMarkName, TiptapNodeName, TiptapRange, TiptapSchemaTarget, TiptapSelectionState, TiptapTextAlign, TiptapTocNodeAttrs, }; @@ -19,7 +18,6 @@ use web_sys::{ MouseEvent, Node, RequestInit, RequestMode, Response, WheelEvent, }; -use editor_runtime::attachment_upload::dispatch_editor_upload_request; use editor_runtime::block_dnd::{ block_state_from_index as block_state_from_index_runtime, drop_indicator_from_point as drop_indicator_from_point_runtime, @@ -33,7 +31,11 @@ use editor_runtime::block_menu_document::{ collect_plain_text, mindmap_paragraph_node, paragraph_node, }; use editor_runtime::block_menu_overlay; -use editor_runtime::block_transform::apply_block_delta_to_json; +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, run_slash_action, top_level_block_is_page, + top_level_block_matches_action, +}; use editor_runtime::bridge_dispatch::HostCommandKind; use editor_runtime::bridge_events::{ BridgeSelectorsPayload, ChangeMetaPayload, ChangePayload, HeightPayload, HostCommandEnvelope, @@ -1315,142 +1317,6 @@ fn apply_host_document_payload( } } -fn apply_text_color(editor: TiptapEditorHandle, color: &str) -> Result<&'static str, String> { - editor - .set_color(TiptapColorAttributes { - color: color.to_string(), - }) - .map(|_| "已更新文字颜色") - .map_err(|err| format!("命令执行失败:{err}")) -} - -fn clear_text_color(editor: TiptapEditorHandle) -> Result<&'static str, String> { - editor - .unset_color() - .map(|_| "已清除文字颜色") - .map_err(|err| format!("命令执行失败:{err}")) -} - -fn apply_highlight_color( - editor: TiptapEditorHandle, - color: Option<&str>, -) -> Result<&'static str, String> { - match color { - Some(color) => editor - .set_highlight(Some(TiptapHighlightAttributes { - color: Some(color.to_string()), - })) - .map(|_| "已更新背景色") - .map_err(|err| format!("命令执行失败:{err}")), - None => editor - .unset_highlight() - .map(|_| "已清除背景色") - .map_err(|err| format!("命令执行失败:{err}")), - } -} - -fn apply_text_align( - editor: TiptapEditorHandle, - alignment: TiptapTextAlign, -) -> Result<&'static str, String> { - let message = match alignment { - TiptapTextAlign::Left => "已切到左对齐", - TiptapTextAlign::Center => "已切到居中", - TiptapTextAlign::Right => "已切到右对齐", - TiptapTextAlign::Justify => "已切到两端对齐", - }; - editor - .set_text_align(alignment) - .map(|_| message) - .map_err(|err| format!("命令执行失败:{err}")) -} - -fn apply_image_align( - editor: TiptapEditorHandle, - align: &'static str, -) -> Result<&'static str, String> { - let _ = editor.focus(); - let mut attrs = leptos_tiptap::TiptapAttributes::new(); - attrs.insert("data-align", align); - editor - .update_attributes(TiptapSchemaTarget::Node(TiptapNodeName::Image), attrs) - .map(|_| "已更新图片对齐") - .map_err(|err| format!("命令执行失败:{err}")) -} - -fn run_slash_action( - editor: TiptapEditorHandle, - action: SlashActionKind, -) -> Result<&'static str, String> { - let result = match action { - SlashActionKind::AiAssistant - | SlashActionKind::AiWrite - | SlashActionKind::ContinueWriting - | SlashActionKind::Summarize - | SlashActionKind::MoreAi => return Ok("AI 命令入口已打开"), - SlashActionKind::Paragraph => editor.set_paragraph(), - SlashActionKind::Heading1 => editor.toggle_heading(TiptapHeadingLevel::H1), - SlashActionKind::Heading2 => editor.toggle_heading(TiptapHeadingLevel::H2), - SlashActionKind::Heading3 => editor.toggle_heading(TiptapHeadingLevel::H3), - SlashActionKind::Heading4 => editor.toggle_heading(TiptapHeadingLevel::H4), - SlashActionKind::BulletList => editor.toggle_bullet_list(), - SlashActionKind::OrderedList => editor.toggle_ordered_list(), - SlashActionKind::Todo | SlashActionKind::AdvancedTodo => editor.toggle_task_list(), - SlashActionKind::Quote => editor.toggle_blockquote(), - SlashActionKind::CodeBlock => editor.toggle_code_block(Some(TiptapCodeBlockAttributes { - language: Some("rust".into()), - })), - SlashActionKind::Divider => editor.set_horizontal_rule(), - SlashActionKind::SimpleTable => { - let _ = editor.focus(); - editor.insert_table(4, 3, false) - } - SlashActionKind::Mindmap => editor.insert_content( - TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)), - None, - ), - SlashActionKind::Image => { - dispatch_editor_upload_request("image", "image/*")?; - return Ok("已打开图片上传"); - } - SlashActionKind::UploadAttachment => { - dispatch_editor_upload_request("attachment", "")?; - return Ok("已打开附件上传"); - } - SlashActionKind::Toc => editor.insert_toc_node(TiptapTocNodeAttrs { - top_offset: Some(0), - max_show_count: Some(20), - show_title: Some(true), - }), - }; - - result - .map(|_| match action { - SlashActionKind::AiAssistant - | SlashActionKind::AiWrite - | SlashActionKind::ContinueWriting - | SlashActionKind::Summarize - | SlashActionKind::MoreAi => "AI 命令入口已打开", - SlashActionKind::Paragraph => "已切到文本", - SlashActionKind::Heading1 => "已切到一级标题", - SlashActionKind::Heading2 => "已切到二级标题", - SlashActionKind::Heading3 => "已切到三级标题", - SlashActionKind::Heading4 => "已切到四级标题", - SlashActionKind::BulletList => "已切到无序列表", - SlashActionKind::OrderedList => "已切到有序列表", - SlashActionKind::Todo | SlashActionKind::AdvancedTodo => "已切到待办列表", - SlashActionKind::Quote => "已切到引述文字", - SlashActionKind::CodeBlock => "已切到代码块", - SlashActionKind::Divider => "已插入分割线", - SlashActionKind::SimpleTable => "已插入简单表格", - SlashActionKind::Mindmap => "已插入思维导图", - SlashActionKind::Image => "已打开图片上传", - SlashActionKind::UploadAttachment => "已打开附件上传", - SlashActionKind::Toc => "已插入页面目录", - }) - .map_err(|err| format!("命令执行失败:{err}")) -} - fn run_block_turn_into_action( editor: TiptapEditorHandle, block_index: usize, @@ -1709,117 +1575,6 @@ fn block_plain_text_from_index( Ok(normalized_page_block_title(&collect_plain_text(block))) } -fn inline_has_page_block_link(node: &Value) -> bool { - if node - .get("marks") - .and_then(Value::as_array) - .map(|marks| { - marks.iter().any(|mark| { - mark.get("type").and_then(Value::as_str) == Some("link") - && mark - .get("attrs") - .and_then(|attrs| attrs.get("class")) - .and_then(Value::as_str) - .map(|class_name| { - class_name - .split_whitespace() - .any(|part| part == "mnote-page-block-link") - }) - .unwrap_or(false) - }) - }) - .unwrap_or(false) - { - return true; - } - - node.get("content") - .and_then(Value::as_array) - .map(|children| children.iter().any(inline_has_page_block_link)) - .unwrap_or(false) -} - -fn top_level_block_is_page(editor: TiptapEditorHandle, block_index: usize) -> bool { - editor - .get_json() - .ok() - .and_then(|document| { - document - .get("content") - .and_then(Value::as_array) - .and_then(|content| content.get(block_index).cloned()) - }) - .map(|block| inline_has_page_block_link(&block)) - .unwrap_or(false) -} - -fn top_level_block_matches_action( - editor: TiptapEditorHandle, - block_index: usize, - action: SlashActionKind, -) -> bool { - let Some(block) = editor.get_json().ok().and_then(|document| { - document - .get("content") - .and_then(Value::as_array) - .and_then(|content| content.get(block_index).cloned()) - }) else { - return false; - }; - - let node_type = block - .get("type") - .and_then(Value::as_str) - .unwrap_or_default(); - match action { - SlashActionKind::Paragraph => { - node_type == "paragraph" && !inline_has_page_block_link(&block) - } - SlashActionKind::Heading1 - | SlashActionKind::Heading2 - | SlashActionKind::Heading3 - | SlashActionKind::Heading4 => { - let expected_level = match action { - SlashActionKind::Heading1 => 1, - SlashActionKind::Heading2 => 2, - SlashActionKind::Heading3 => 3, - SlashActionKind::Heading4 => 4, - _ => 0, - }; - node_type == "heading" - && block - .get("attrs") - .and_then(|attrs| attrs.get("level")) - .and_then(Value::as_i64) - .map(|level| level == expected_level) - .unwrap_or(false) - } - SlashActionKind::BulletList => node_type == "bulletList", - SlashActionKind::OrderedList => node_type == "orderedList", - SlashActionKind::Todo | SlashActionKind::AdvancedTodo => node_type == "taskList", - SlashActionKind::Quote => node_type == "blockquote", - SlashActionKind::CodeBlock => node_type == "codeBlock", - SlashActionKind::Divider => node_type == "horizontalRule", - SlashActionKind::SimpleTable => node_type == "table", - SlashActionKind::Mindmap => { - node_type == "paragraph" - && block - .get("attrs") - .and_then(|attrs| attrs.get("mnoteBlockType")) - .and_then(Value::as_str) - == Some("mindmap") - } - SlashActionKind::Image => node_type == "image", - SlashActionKind::UploadAttachment => false, - SlashActionKind::Toc => node_type == "tocNode", - SlashActionKind::AiAssistant - | SlashActionKind::AiWrite - | SlashActionKind::ContinueWriting - | SlashActionKind::Summarize - | SlashActionKind::MoreAi => false, - } -} - fn page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, title: &str) -> Value { let href = if page_id.trim().is_empty() { "#".to_string()