主编辑区改造准备

This commit is contained in:
lix-2026
2026-04-21 06:26:35 +08:00
parent 1e686bfa3c
commit 5d1c94eb9e
49 changed files with 6730 additions and 2565 deletions
@@ -1,6 +1,7 @@
pub mod command;
pub mod markdown;
pub mod model;
pub mod tiptap;
pub use command::{
EditorAttachReferenceToken, EditorCommand, EditorCommandCatalog, EditorCommandDescriptor,
@@ -17,6 +18,10 @@ pub use model::{
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
};
pub use tiptap::{
EditorBlockDocumentTiptapBridge, EditorBlockDocumentTiptapError, TiptapBlockType,
TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapNode, TiptapTaskItemAttrs,
};
#[cfg(test)]
mod tests {
@@ -0,0 +1,494 @@
use crate::editor::model::{
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
TextMark,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TiptapBlockType {
Paragraph,
Heading,
BulletList,
OrderedList,
TaskList,
ListItem,
TaskItem,
Blockquote,
CodeBlock,
Text,
HardBreak,
Doc,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TiptapHeadingAttrs {
pub level: u8,
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapTaskItemAttrs {
#[serde(default)]
pub checked: bool,
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapParagraphAttrs {
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapListAttrs {
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapListItemAttrs {
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapBlockquoteAttrs {
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TiptapCodeBlockAttrs {
pub language: Option<String>,
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TiptapMark {
pub r#type: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum TiptapNode {
Doc {
content: Vec<TiptapNode>,
},
Paragraph {
#[serde(default)]
attrs: TiptapParagraphAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
Heading {
attrs: TiptapHeadingAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
BulletList {
#[serde(default)]
attrs: TiptapListAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
OrderedList {
#[serde(default)]
attrs: TiptapListAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
TaskList {
#[serde(default)]
attrs: TiptapListAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
ListItem {
#[serde(default)]
attrs: TiptapListItemAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
TaskItem {
attrs: TiptapTaskItemAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
Blockquote {
#[serde(default)]
attrs: TiptapBlockquoteAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
CodeBlock {
attrs: TiptapCodeBlockAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
Text {
text: String,
#[serde(default)]
marks: Vec<TiptapMark>,
},
HardBreak,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditorBlockDocumentTiptapError {
UnsupportedNode(&'static str),
InvalidDocument(&'static str),
}
pub struct EditorBlockDocumentTiptapBridge;
impl EditorBlockDocumentTiptapBridge {
pub fn to_tiptap_doc(
document: &EditorBlockDocument,
) -> Result<TiptapNode, EditorBlockDocumentTiptapError> {
let mut content = Vec::new();
for block_id in &document.root_block_ids {
let block = document
.blocks
.iter()
.find(|block| &block.block_id == block_id)
.ok_or(EditorBlockDocumentTiptapError::InvalidDocument(
"根块不存在",
))?;
content.push(block_to_tiptap_node(block)?);
}
Ok(TiptapNode::Doc { content })
}
pub fn from_tiptap_doc(
document_id: impl Into<String>,
doc: &TiptapNode,
) -> Result<EditorBlockDocument, EditorBlockDocumentTiptapError> {
match doc {
TiptapNode::Doc { content } => {
let mut blocks = Vec::new();
let mut root_block_ids = Vec::new();
for (index, node) in content.iter().enumerate() {
let block = node_to_block(node, index)?;
root_block_ids.push(block.block_id.clone());
blocks.push(block);
}
Ok(EditorBlockDocument {
document_id: document_id.into(),
root_block_ids,
blocks,
})
}
_ => Err(EditorBlockDocumentTiptapError::InvalidDocument(
"顶层必须是 doc",
)),
}
}
}
fn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDocumentTiptapError> {
let content = text_nodes_to_tiptap(&block.content_nodes)?;
match block.block_type {
EditorBlockType::Paragraph => Ok(TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}),
EditorBlockType::Heading => Ok(TiptapNode::Heading {
attrs: TiptapHeadingAttrs {
level: block.props.heading_level.unwrap_or(1),
block_id: Some(block.block_id.clone()),
},
content,
}),
EditorBlockType::BulletListItem => Ok(TiptapNode::BulletList {
attrs: TiptapListAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::ListItem {
attrs: TiptapListItemAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}],
}],
}),
EditorBlockType::NumberedListItem => Ok(TiptapNode::OrderedList {
attrs: TiptapListAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::ListItem {
attrs: TiptapListItemAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}],
}],
}),
EditorBlockType::Todo => Ok(TiptapNode::TaskList {
attrs: TiptapListAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::TaskItem {
attrs: TiptapTaskItemAttrs {
checked: block.props.checked.unwrap_or(false),
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}],
}],
}),
EditorBlockType::Quote => Ok(TiptapNode::Blockquote {
attrs: TiptapBlockquoteAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}],
}),
EditorBlockType::CodeBlock => Ok(TiptapNode::CodeBlock {
attrs: TiptapCodeBlockAttrs {
language: block.props.language.clone(),
block_id: Some(block.block_id.clone()),
},
content,
}),
_ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(
"当前块型暂不支持导出到 Tiptap",
)),
}
}
fn node_to_block(
node: &TiptapNode,
index: usize,
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
let fallback_block_id = format!("block_{}", index + 1);
match node {
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::Paragraph,
props: BlockProps::default(),
content_nodes: text_nodes_from_tiptap(content)?,
child_block_ids: vec![],
}),
TiptapNode::Heading { attrs, content } => Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::Heading,
props: BlockProps {
heading_level: Some(attrs.level),
..BlockProps::default()
},
content_nodes: text_nodes_from_tiptap(content)?,
child_block_ids: vec![],
}),
TiptapNode::BulletList { attrs, content } => list_node_to_block(
attrs.block_id.clone().unwrap_or(fallback_block_id),
EditorBlockType::BulletListItem,
content,
),
TiptapNode::OrderedList { attrs, content } => list_node_to_block(
attrs.block_id.clone().unwrap_or(fallback_block_id),
EditorBlockType::NumberedListItem,
content,
),
TiptapNode::TaskList { attrs, content } => {
list_task_node_to_block(attrs.block_id.clone().unwrap_or(fallback_block_id), content)
}
TiptapNode::Blockquote { attrs, content } => Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::Quote,
props: BlockProps::default(),
content_nodes: text_nodes_from_tiptap(extract_block_container_content(content)?)?,
child_block_ids: vec![],
}),
TiptapNode::CodeBlock { attrs, content } => Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::CodeBlock,
props: BlockProps {
language: attrs.language.clone(),
..BlockProps::default()
},
content_nodes: text_nodes_from_tiptap(content)?,
child_block_ids: vec![],
}),
_ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(
"当前节点暂不支持导入为 EditorBlockDocument",
)),
}
}
fn list_node_to_block(
block_id: String,
block_type: EditorBlockType,
content: &[TiptapNode],
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
Ok(EditorBlock {
block_id,
block_type,
props: BlockProps::default(),
content_nodes: text_nodes_from_tiptap(extract_list_item_inline_content(content)?)?,
child_block_ids: vec![],
})
}
fn list_task_node_to_block(
block_id: String,
content: &[TiptapNode],
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
let Some(first) = content.first() else {
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
"任务列表节点缺少 task_item",
));
};
let (checked, item_content) = match first {
TiptapNode::TaskItem { attrs, content } => (
Some(attrs.checked),
extract_block_container_content(content)?,
),
_ => {
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
"任务列表节点首子节点类型不正确",
))
}
};
Ok(EditorBlock {
block_id,
block_type: EditorBlockType::Todo,
props: BlockProps {
checked,
..BlockProps::default()
},
content_nodes: text_nodes_from_tiptap(item_content)?,
child_block_ids: vec![],
})
}
fn extract_list_item_inline_content(
content: &[TiptapNode],
) -> Result<&[TiptapNode], EditorBlockDocumentTiptapError> {
let Some(first) = content.first() else {
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
"列表节点缺少 list_item/task_item",
));
};
match first {
TiptapNode::ListItem { content, .. } | TiptapNode::TaskItem { content, .. } => {
extract_block_container_content(content)
}
_ => Err(EditorBlockDocumentTiptapError::InvalidDocument(
"列表节点首子节点类型不正确",
)),
}
}
fn extract_block_container_content(
content: &[TiptapNode],
) -> Result<&[TiptapNode], EditorBlockDocumentTiptapError> {
let Some(first) = content.first() else {
return Ok(content);
};
match first {
TiptapNode::Paragraph { content, .. } => Ok(content),
_ => Ok(content),
}
}
fn text_nodes_to_tiptap(
nodes: &[ContentNode],
) -> Result<Vec<TiptapNode>, EditorBlockDocumentTiptapError> {
let mut out = Vec::new();
for node in nodes {
match &node.payload {
ContentNodePayload::Text { text, marks } => out.push(TiptapNode::Text {
text: text.clone(),
marks: marks.iter().map(text_mark_to_tiptap).collect(),
}),
ContentNodePayload::HardBreak => out.push(TiptapNode::HardBreak),
ContentNodePayload::ReferenceToken { .. } => {
return Err(EditorBlockDocumentTiptapError::UnsupportedNode(
"引用 token 暂不映射到 Tiptap 文本合同",
))
}
}
}
Ok(out)
}
fn text_nodes_from_tiptap(
nodes: &[TiptapNode],
) -> Result<Vec<ContentNode>, EditorBlockDocumentTiptapError> {
let mut out = Vec::new();
for node in nodes {
match node {
TiptapNode::Text { text, marks } => out.push(ContentNode {
payload: ContentNodePayload::Text {
text: text.clone(),
marks: marks.iter().filter_map(tiptap_mark_to_text_mark).collect(),
},
attrs: BTreeMap::new(),
}),
TiptapNode::HardBreak => out.push(ContentNode {
payload: ContentNodePayload::HardBreak,
attrs: BTreeMap::new(),
}),
_ => {
return Err(EditorBlockDocumentTiptapError::UnsupportedNode(
"当前只支持文本节点内容",
))
}
}
}
Ok(out)
}
fn text_mark_to_tiptap(mark: &TextMark) -> TiptapMark {
let r#type = match mark {
TextMark::Bold => "bold",
TextMark::Italic => "italic",
TextMark::Underline => "underline",
TextMark::Strike => "strike",
TextMark::Code => "code",
};
TiptapMark {
r#type: r#type.to_string(),
}
}
fn tiptap_mark_to_text_mark(mark: &TiptapMark) -> Option<TextMark> {
match mark.r#type.as_str() {
"bold" => Some(TextMark::Bold),
"italic" => Some(TextMark::Italic),
"underline" => Some(TextMark::Underline),
"strike" => Some(TextMark::Strike),
"code" => Some(TextMark::Code),
_ => None,
}
}