主编辑区改造准备
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user