refactor: prune block transform helpers

This commit is contained in:
lix-2026
2026-05-25 06:27:00 +08:00
parent 8bcf958fe8
commit 0509081bbb
6 changed files with 85 additions and 212 deletions
@@ -1,6 +1,6 @@
//! block menu 使用的顶层块 JSON helper。
use serde_json::{json, Value};
use serde_json::{json, Map, Value};
use crate::editor_runtime::block_hover_state::DropPlacement;
@@ -65,6 +65,57 @@ pub(crate) fn reorder_top_level_block(
Ok(())
}
fn node_with_attrs(
type_name: &str,
attrs: Option<Map<String, Value>>,
content: Vec<Value>,
) -> Value {
let mut node = Map::new();
node.insert("type".to_string(), Value::String(type_name.to_string()));
if let Some(attrs) = attrs.filter(|attrs| !attrs.is_empty()) {
node.insert("attrs".to_string(), Value::Object(attrs));
}
if !content.is_empty() {
node.insert("content".to_string(), Value::Array(content));
}
Value::Object(node)
}
pub(crate) fn paragraph_node(content: Vec<Value>) -> Value {
node_with_attrs("paragraph", None, content)
}
pub(crate) fn collect_plain_text(node: &Value) -> String {
match node.get("type").and_then(Value::as_str) {
Some("text") => node
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
Some("hardBreak") => "\n".to_string(),
_ => node
.get("content")
.and_then(Value::as_array)
.map(|children| {
children
.iter()
.map(collect_plain_text)
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default(),
}
}
pub(crate) fn mindmap_paragraph_node(next_mindmap_id: impl FnOnce() -> String) -> Value {
let mut attrs = Map::new();
attrs.insert("mnoteBlockType".to_string(), json!("mindmap"));
attrs.insert("mindmapId".to_string(), json!(next_mindmap_id()));
attrs.insert("rootNodeId".to_string(), json!("root"));
attrs.insert("projectionVersion".to_string(), json!(1));
node_with_attrs("paragraph", Some(attrs), Vec::new())
}
#[cfg(test)]
mod tests {
use serde_json::json;
+13 -202
View File
@@ -11,7 +11,7 @@ use leptos_tiptap::{
};
use send_wrapper::SendWrapper;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use serde_json::{json, Value};
use std::{any::Any, cell::Cell, collections::HashMap, fmt::Display};
use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue};
use wasm_bindgen_futures::{spawn_local, JsFuture};
@@ -27,6 +27,9 @@ use editor_runtime::block_dnd::{
HandleCorridorGeometry,
};
use editor_runtime::block_menu_overlay;
use editor_runtime::block_menu_document::{
collect_plain_text, mindmap_paragraph_node, paragraph_node,
};
use editor_runtime::block_menu_legacy_html::{
duplicate_top_level_block_html, reorder_top_level_block_html,
};
@@ -81,10 +84,6 @@ const MINDMAP_SHELL_MINIMAP_EVENT: &str = "mnote:mindmap-shell:minimap";
const MINDMAP_SHELL_ZOOM_EVENT: &str = "mnote:mindmap-shell:zoom";
const MINDMAP_SHELL_TOOLBAR_OVERFLOW_EVENT: &str = "mnote:mindmap-shell:toolbar-overflow";
const STANDALONE_ROOT_ID: &str = "mnote-leptos-tiptap-standalone-root";
const E24_IMAGE_PLACEHOLDER_SRC: &str = "/api/editor/image-placeholder.svg";
const E24_IMAGE_PLACEHOLDER_ALT: &str = "E24 图片占位";
const E24_IMAGE_PLACEHOLDER_TITLE: &str = "E24 图片";
mod editor_runtime;
#[wasm_bindgen(inline_js = r#"
@@ -5261,26 +5260,6 @@ fn document_content_mut(document: &mut Value) -> Result<&mut Vec<Value>, String>
.ok_or_else(|| "文档 JSON 缺少顶层 content 数组".to_string())
}
fn node_with_attrs(
type_name: &str,
attrs: Option<Map<String, Value>>,
content: Vec<Value>,
) -> Value {
let mut node = Map::new();
node.insert("type".to_string(), Value::String(type_name.to_string()));
if let Some(attrs) = attrs.filter(|attrs| !attrs.is_empty()) {
node.insert("attrs".to_string(), Value::Object(attrs));
}
if !content.is_empty() {
node.insert("content".to_string(), Value::Array(content));
}
Value::Object(node)
}
fn paragraph_node(content: Vec<Value>) -> Value {
node_with_attrs("paragraph", None, content)
}
fn next_mindmap_id() -> String {
let now = js_sys::Date::new_0();
format!(
@@ -5291,181 +5270,6 @@ fn next_mindmap_id() -> String {
)
}
fn mindmap_paragraph_node() -> Value {
let mut attrs = Map::new();
attrs.insert("mnoteBlockType".to_string(), json!("mindmap"));
attrs.insert("mindmapId".to_string(), json!(next_mindmap_id()));
attrs.insert("rootNodeId".to_string(), json!("root"));
attrs.insert("projectionVersion".to_string(), json!(1));
node_with_attrs("paragraph", Some(attrs), Vec::new())
}
fn list_item_node(content: Vec<Value>) -> Value {
node_with_attrs("listItem", None, vec![paragraph_node(content)])
}
fn task_item_node(content: Vec<Value>) -> Value {
let mut attrs = Map::new();
attrs.insert("checked".to_string(), Value::Bool(false));
node_with_attrs("taskItem", Some(attrs), vec![paragraph_node(content)])
}
fn collect_plain_text(node: &Value) -> String {
match node.get("type").and_then(Value::as_str) {
Some("text") => node
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
Some("hardBreak") => "\n".to_string(),
_ => node
.get("content")
.and_then(Value::as_array)
.map(|children| {
children
.iter()
.map(collect_plain_text)
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default(),
}
}
fn inline_from_first_child(children: &[Value]) -> Vec<Value> {
children
.first()
.map(extract_inline_content)
.unwrap_or_default()
}
fn extract_inline_content(node: &Value) -> Vec<Value> {
match node.get("type").and_then(Value::as_str) {
Some("paragraph") | Some("heading") => node
.get("content")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default(),
Some("blockquote") => node
.get("content")
.and_then(Value::as_array)
.map(|children| inline_from_first_child(children))
.unwrap_or_default(),
Some("bulletList") | Some("orderedList") | Some("taskList") => node
.get("content")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("content"))
.and_then(Value::as_array)
.map(|children| inline_from_first_child(children))
.unwrap_or_default(),
Some("codeBlock") => {
let text = collect_plain_text(node);
if text.is_empty() {
Vec::new()
} else {
vec![json!({"type": "text", "text": text})]
}
}
Some("horizontalRule") => Vec::new(),
_ => node
.get("content")
.and_then(Value::as_array)
.map(|children| {
children
.iter()
.flat_map(extract_inline_content)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
}
}
fn turn_into_block(node: &Value, action: SlashActionKind) -> Value {
let inline = extract_inline_content(node);
match action {
SlashActionKind::AiAssistant
| SlashActionKind::AiWrite
| SlashActionKind::ContinueWriting
| SlashActionKind::Summarize
| SlashActionKind::MoreAi => paragraph_node(inline.clone()),
SlashActionKind::Paragraph => paragraph_node(inline),
SlashActionKind::Heading1 => {
let mut attrs = Map::new();
attrs.insert("level".to_string(), json!(1));
attrs.insert("collapsed".to_string(), Value::Null);
node_with_attrs("heading", Some(attrs), inline)
}
SlashActionKind::Heading2 => {
let mut attrs = Map::new();
attrs.insert("level".to_string(), json!(2));
attrs.insert("collapsed".to_string(), Value::Null);
node_with_attrs("heading", Some(attrs), inline)
}
SlashActionKind::Heading3 => {
let mut attrs = Map::new();
attrs.insert("level".to_string(), json!(3));
attrs.insert("collapsed".to_string(), Value::Null);
node_with_attrs("heading", Some(attrs), inline)
}
SlashActionKind::Heading4 => {
let mut attrs = Map::new();
attrs.insert("level".to_string(), json!(4));
attrs.insert("collapsed".to_string(), Value::Null);
node_with_attrs("heading", Some(attrs), inline)
}
SlashActionKind::BulletList => {
node_with_attrs("bulletList", None, vec![list_item_node(inline)])
}
SlashActionKind::OrderedList => {
node_with_attrs("orderedList", None, vec![list_item_node(inline)])
}
SlashActionKind::Todo | SlashActionKind::AdvancedTodo => {
node_with_attrs("taskList", None, vec![task_item_node(inline)])
}
SlashActionKind::Quote => node_with_attrs("blockquote", None, vec![paragraph_node(inline)]),
SlashActionKind::CodeBlock => {
let text = collect_plain_text(node);
let mut attrs = Map::new();
attrs.insert("language".to_string(), Value::String("rust".to_string()));
let content = if text.is_empty() {
Vec::new()
} else {
vec![json!({"type": "text", "text": text})]
};
node_with_attrs("codeBlock", Some(attrs), content)
}
SlashActionKind::Divider => node_with_attrs("horizontalRule", None, Vec::new()),
SlashActionKind::SimpleTable | SlashActionKind::UploadAttachment | SlashActionKind::Toc => {
paragraph_node(inline)
}
SlashActionKind::Mindmap => mindmap_paragraph_node(),
SlashActionKind::Image => {
let mut attrs = Map::new();
attrs.insert("src".to_string(), json!(E24_IMAGE_PLACEHOLDER_SRC));
attrs.insert("alt".to_string(), json!(E24_IMAGE_PLACEHOLDER_ALT));
attrs.insert("title".to_string(), json!(E24_IMAGE_PLACEHOLDER_TITLE));
node_with_attrs("image", Some(attrs), Vec::new())
}
}
}
fn replace_top_level_block_kind(
document: &mut Value,
index: usize,
action: SlashActionKind,
) -> Result<(), String> {
let content = document_content_mut(document)?;
let next = turn_into_block(
content
.get(index)
.ok_or_else(|| format!("找不到第 {index} 个块"))?,
action,
);
content[index] = next;
Ok(())
}
fn apply_html_update(
editor: TiptapEditorHandle,
persisted_identity: &PersistedDocumentIdentity,
@@ -6185,7 +5989,10 @@ fn run_slash_action(
editor.insert_table(4, 3, false)
}
SlashActionKind::Mindmap => {
editor.insert_content(TiptapContent::json(mindmap_paragraph_node()), None)
editor.insert_content(
TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)),
None,
)
}
SlashActionKind::Image => {
dispatch_editor_upload_request("image", "image/*")?;
@@ -6272,7 +6079,11 @@ fn run_block_turn_into_action(
.map(|_| "已把当前块转成分割线")
.map_err(|err| format!("命令执行失败:{err}")),
SlashActionKind::Mindmap => editor
.insert_content_at(range, TiptapContent::json(mindmap_paragraph_node()), None)
.insert_content_at(
range,
TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)),
None,
)
.map(|_| "已把当前块转成思维导图")
.map_err(|err| format!("命令执行失败:{err}")),
_ => {