主编辑区改造准备

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,
}
}
@@ -0,0 +1,216 @@
use core_protocol::editor::{
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument,
EditorBlockDocumentTiptapBridge, EditorBlockType, TextMark, TiptapCodeBlockAttrs,
TiptapHeadingAttrs, TiptapNode, TiptapParagraphAttrs, TiptapTaskItemAttrs,
};
use std::collections::BTreeMap;
#[test]
fn p05_blocks_round_trip_through_tiptap_doc() {
let document = EditorBlockDocument {
document_id: "doc_1".into(),
root_block_ids: vec![
"p1".into(),
"h1".into(),
"b1".into(),
"n1".into(),
"t1".into(),
"q1".into(),
"c1".into(),
],
blocks: vec![
EditorBlock {
block_id: "p1".into(),
block_type: EditorBlockType::Paragraph,
props: BlockProps::default(),
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "段落".into(),
marks: vec![TextMark::Bold],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "h1".into(),
block_type: EditorBlockType::Heading,
props: BlockProps {
heading_level: Some(2),
..BlockProps::default()
},
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "标题".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "b1".into(),
block_type: EditorBlockType::BulletListItem,
props: BlockProps::default(),
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "项目".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "n1".into(),
block_type: EditorBlockType::NumberedListItem,
props: BlockProps::default(),
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "序号".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "t1".into(),
block_type: EditorBlockType::Todo,
props: BlockProps {
checked: Some(true),
..BlockProps::default()
},
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "待办".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "q1".into(),
block_type: EditorBlockType::Quote,
props: BlockProps::default(),
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "引用".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "c1".into(),
block_type: EditorBlockType::CodeBlock,
props: BlockProps {
language: Some("rust".into()),
..BlockProps::default()
},
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "fn main() {}".into(),
marks: vec![TextMark::Code],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
],
};
let doc =
EditorBlockDocumentTiptapBridge::to_tiptap_doc(&document).expect("export should work");
let round_trip = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_2", &doc)
.expect("import should work");
assert_eq!(
round_trip.root_block_ids,
vec!["p1", "h1", "b1", "n1", "t1", "q1", "c1"]
);
assert_eq!(round_trip.blocks[0].block_id, "p1");
assert_eq!(round_trip.blocks[0].content_nodes.len(), 1);
match &round_trip.blocks[0].content_nodes[0].payload {
ContentNodePayload::Text { marks, .. } => assert_eq!(marks, &vec![TextMark::Bold]),
other => panic!("unexpected payload: {other:?}"),
}
assert_eq!(round_trip.blocks[1].props.heading_level, Some(2));
assert_eq!(round_trip.blocks[4].props.checked, Some(true));
assert_eq!(round_trip.blocks[6].props.language.as_deref(), Some("rust"));
}
#[test]
fn tiptap_import_preserves_explicit_block_ids_and_marks() {
let doc = TiptapNode::Doc {
content: vec![
TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some("p-1".into()),
},
content: vec![TiptapNode::Text {
text: "Hello".into(),
marks: vec![core_protocol::editor::TiptapMark {
r#type: "italic".into(),
}],
}],
},
TiptapNode::Heading {
attrs: TiptapHeadingAttrs {
level: 3,
block_id: Some("h-1".into()),
},
content: vec![TiptapNode::Text {
text: "H".into(),
marks: vec![],
}],
},
TiptapNode::TaskList {
attrs: core_protocol::editor::TiptapListAttrs {
block_id: Some("t-1".into()),
},
content: vec![TiptapNode::TaskItem {
attrs: TiptapTaskItemAttrs {
checked: true,
block_id: Some("t-1".into()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some("t-1".into()),
},
content: vec![TiptapNode::Text {
text: "todo".into(),
marks: vec![],
}],
}],
}],
},
TiptapNode::CodeBlock {
attrs: TiptapCodeBlockAttrs {
language: Some("ts".into()),
block_id: Some("c-1".into()),
},
content: vec![TiptapNode::Text {
text: "let x = 1;".into(),
marks: vec![core_protocol::editor::TiptapMark {
r#type: "code".into(),
}],
}],
},
],
};
let imported = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_3", &doc).unwrap();
assert_eq!(imported.root_block_ids, vec!["p-1", "h-1", "t-1", "c-1"]);
assert_eq!(imported.blocks[0].block_id, "p-1");
assert_eq!(imported.blocks[1].props.heading_level, Some(3));
assert_eq!(imported.blocks[2].props.checked, Some(true));
assert_eq!(imported.blocks[3].props.language.as_deref(), Some("ts"));
match &imported.blocks[0].content_nodes[0].payload {
ContentNodePayload::Text { marks, .. } => assert_eq!(marks, &vec![TextMark::Italic]),
other => panic!("unexpected payload: {other:?}"),
}
}