refactor: split tiptap block transform helpers

This commit is contained in:
lix-2026
2026-05-26 03:21:11 +08:00
parent cf7a57d86a
commit 396c64e09e
3 changed files with 275 additions and 252 deletions
@@ -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 行为,不新增编辑器功能。
@@ -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 {
+6 -251
View File
@@ -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()