Checkpoint current workspace
This commit is contained in:
@@ -12,6 +12,10 @@ pub enum EditorBlockType {
|
||||
Quote,
|
||||
Todo,
|
||||
CodeBlock,
|
||||
Divider,
|
||||
Table,
|
||||
Image,
|
||||
Toc,
|
||||
PageReference,
|
||||
BlockReference,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::editor::model::{
|
||||
TextMark,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -17,6 +18,13 @@ pub enum TiptapBlockType {
|
||||
TaskItem,
|
||||
Blockquote,
|
||||
CodeBlock,
|
||||
HorizontalRule,
|
||||
Table,
|
||||
Image,
|
||||
TocNode,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Text,
|
||||
HardBreak,
|
||||
Doc,
|
||||
@@ -28,6 +36,10 @@ pub struct TiptapHeadingAttrs {
|
||||
pub level: u8,
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_align: Option<String>,
|
||||
#[serde(default)]
|
||||
pub collapsed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -44,6 +56,8 @@ pub struct TiptapTaskItemAttrs {
|
||||
pub struct TiptapParagraphAttrs {
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_align: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -65,6 +79,8 @@ pub struct TiptapListItemAttrs {
|
||||
pub struct TiptapBlockquoteAttrs {
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_align: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -72,6 +88,66 @@ pub struct TiptapBlockquoteAttrs {
|
||||
pub struct TiptapCodeBlockAttrs {
|
||||
pub language: Option<String>,
|
||||
pub block_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_align: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapHorizontalRuleAttrs {
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapImageAttrs {
|
||||
pub src: String,
|
||||
#[serde(default)]
|
||||
pub alt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapTableAttrs {
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapTocNodeAttrs {
|
||||
#[serde(default)]
|
||||
pub top_offset: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_show_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub show_title: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapTableCellAttrs {
|
||||
#[serde(default)]
|
||||
pub colspan: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub rowspan: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub colwidth: Value,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -137,6 +213,41 @@ pub enum TiptapNode {
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
HorizontalRule {
|
||||
#[serde(default)]
|
||||
attrs: TiptapHorizontalRuleAttrs,
|
||||
},
|
||||
Image {
|
||||
attrs: TiptapImageAttrs,
|
||||
},
|
||||
Table {
|
||||
#[serde(default)]
|
||||
attrs: TiptapTableAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
TocNode {
|
||||
#[serde(default)]
|
||||
attrs: TiptapTocNodeAttrs,
|
||||
},
|
||||
TableRow {
|
||||
#[serde(default)]
|
||||
attrs: BTreeMap<String, Value>,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
TableCell {
|
||||
#[serde(default)]
|
||||
attrs: TiptapTableCellAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
TableHeader {
|
||||
#[serde(default)]
|
||||
attrs: TiptapTableCellAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
Text {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
@@ -153,6 +264,113 @@ pub enum EditorBlockDocumentTiptapError {
|
||||
|
||||
pub struct EditorBlockDocumentTiptapBridge;
|
||||
|
||||
fn text_align_from_props(props: &BlockProps) -> Option<String> {
|
||||
props
|
||||
.extra
|
||||
.get("textAlign")
|
||||
.or_else(|| props.extra.get("text_align"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| matches!(*value, "left" | "center" | "right" | "justify"))
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn table_snapshot_from_props(props: &BlockProps) -> Option<TiptapNode> {
|
||||
props
|
||||
.extra
|
||||
.get("tiptapTable")
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value::<TiptapNode>(value).ok())
|
||||
}
|
||||
|
||||
fn image_snapshot_from_props(props: &BlockProps) -> Option<TiptapNode> {
|
||||
props
|
||||
.extra
|
||||
.get("tiptapImage")
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value::<TiptapNode>(value).ok())
|
||||
}
|
||||
|
||||
fn toc_snapshot_from_props(props: &BlockProps) -> Option<TiptapNode> {
|
||||
props
|
||||
.extra
|
||||
.get("tiptapTocNode")
|
||||
.or_else(|| props.extra.get("tiptapToc"))
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value::<TiptapNode>(value).ok())
|
||||
}
|
||||
|
||||
fn props_with_image_snapshot(node: &TiptapNode) -> BlockProps {
|
||||
let mut props = BlockProps::default();
|
||||
if let Ok(value) = serde_json::to_value(node) {
|
||||
props.extra.insert("tiptapImage".into(), value);
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
fn props_with_table_snapshot(node: &TiptapNode) -> BlockProps {
|
||||
let mut props = BlockProps::default();
|
||||
if let Ok(value) = serde_json::to_value(node) {
|
||||
props.extra.insert("tiptapTable".into(), value);
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
fn props_with_toc_snapshot(node: &TiptapNode) -> BlockProps {
|
||||
let mut props = BlockProps::default();
|
||||
if let Ok(value) = serde_json::to_value(node) {
|
||||
props.extra.insert("tiptapTocNode".into(), value);
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
fn collect_table_text_nodes(nodes: &[TiptapNode], out: &mut Vec<ContentNode>) {
|
||||
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(),
|
||||
}),
|
||||
TiptapNode::Doc { content }
|
||||
| TiptapNode::Paragraph { content, .. }
|
||||
| TiptapNode::Heading { content, .. }
|
||||
| TiptapNode::BulletList { content, .. }
|
||||
| TiptapNode::OrderedList { content, .. }
|
||||
| TiptapNode::TaskList { content, .. }
|
||||
| TiptapNode::ListItem { content, .. }
|
||||
| TiptapNode::TaskItem { content, .. }
|
||||
| TiptapNode::Blockquote { content, .. }
|
||||
| TiptapNode::CodeBlock { content, .. }
|
||||
| TiptapNode::Table { content, .. }
|
||||
| TiptapNode::TableRow { content, .. }
|
||||
| TiptapNode::TableCell { content, .. }
|
||||
| TiptapNode::TableHeader { content, .. } => collect_table_text_nodes(content, out),
|
||||
TiptapNode::HorizontalRule { .. }
|
||||
| TiptapNode::Image { .. }
|
||||
| TiptapNode::TocNode { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn props_with_text_align(text_align: Option<String>) -> BlockProps {
|
||||
let mut props = BlockProps::default();
|
||||
if let Some(text_align) = text_align
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| matches!(*value, "left" | "center" | "right" | "justify"))
|
||||
{
|
||||
props.extra.insert("textAlign".into(), text_align.into());
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
impl EditorBlockDocumentTiptapBridge {
|
||||
pub fn to_tiptap_doc(
|
||||
document: &EditorBlockDocument,
|
||||
@@ -166,7 +384,7 @@ impl EditorBlockDocumentTiptapBridge {
|
||||
.ok_or(EditorBlockDocumentTiptapError::InvalidDocument(
|
||||
"根块不存在",
|
||||
))?;
|
||||
content.push(block_to_tiptap_node(block)?);
|
||||
content.push(block_to_tiptap_node(block, document)?);
|
||||
}
|
||||
Ok(TiptapNode::Doc { content })
|
||||
}
|
||||
@@ -180,9 +398,9 @@ impl EditorBlockDocumentTiptapBridge {
|
||||
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);
|
||||
let (node_root_ids, mut node_blocks) = node_to_blocks(node, index)?;
|
||||
root_block_ids.extend(node_root_ids);
|
||||
blocks.append(&mut node_blocks);
|
||||
}
|
||||
Ok(EditorBlockDocument {
|
||||
document_id: document_id.into(),
|
||||
@@ -197,12 +415,82 @@ impl EditorBlockDocumentTiptapBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDocumentTiptapError> {
|
||||
fn node_to_blocks(
|
||||
node: &TiptapNode,
|
||||
index: usize,
|
||||
) -> Result<(Vec<String>, Vec<EditorBlock>), EditorBlockDocumentTiptapError> {
|
||||
match node {
|
||||
TiptapNode::BulletList { attrs, content } => list_node_to_blocks(
|
||||
attrs.block_id.as_deref(),
|
||||
EditorBlockType::BulletListItem,
|
||||
content,
|
||||
&format!("block_{}", index + 1),
|
||||
),
|
||||
TiptapNode::OrderedList { attrs, content } => list_node_to_blocks(
|
||||
attrs.block_id.as_deref(),
|
||||
EditorBlockType::NumberedListItem,
|
||||
content,
|
||||
&format!("block_{}", index + 1),
|
||||
),
|
||||
TiptapNode::TaskList { attrs, content } => list_node_to_blocks(
|
||||
attrs.block_id.as_deref(),
|
||||
EditorBlockType::Todo,
|
||||
content,
|
||||
&format!("block_{}", index + 1),
|
||||
),
|
||||
_ => {
|
||||
let block = node_to_block(node, index)?;
|
||||
Ok((vec![block.block_id.clone()], vec![block]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn block_child_tiptap_nodes(
|
||||
block: &EditorBlock,
|
||||
document: &EditorBlockDocument,
|
||||
) -> Result<Vec<TiptapNode>, EditorBlockDocumentTiptapError> {
|
||||
block
|
||||
.child_block_ids
|
||||
.iter()
|
||||
.map(|child_id| {
|
||||
let child = document
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|candidate| &candidate.block_id == child_id)
|
||||
.ok_or(EditorBlockDocumentTiptapError::InvalidDocument(
|
||||
"子块不存在",
|
||||
))?;
|
||||
block_to_tiptap_node(child, document)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn list_item_content_from_block(
|
||||
block: &EditorBlock,
|
||||
inline_content: Vec<TiptapNode>,
|
||||
document: &EditorBlockDocument,
|
||||
) -> Result<Vec<TiptapNode>, EditorBlockDocumentTiptapError> {
|
||||
let mut content = vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
},
|
||||
content: inline_content,
|
||||
}];
|
||||
content.extend(block_child_tiptap_nodes(block, document)?);
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn block_to_tiptap_node(
|
||||
block: &EditorBlock,
|
||||
document: &EditorBlockDocument,
|
||||
) -> 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()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
},
|
||||
content,
|
||||
}),
|
||||
@@ -210,6 +498,8 @@ fn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDo
|
||||
attrs: TiptapHeadingAttrs {
|
||||
level: block.props.heading_level.unwrap_or(1),
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
collapsed: block.props.collapsed,
|
||||
},
|
||||
content,
|
||||
}),
|
||||
@@ -221,12 +511,7 @@ fn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDo
|
||||
attrs: TiptapListItemAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content,
|
||||
}],
|
||||
content: list_item_content_from_block(block, content, document)?,
|
||||
}],
|
||||
}),
|
||||
EditorBlockType::NumberedListItem => Ok(TiptapNode::OrderedList {
|
||||
@@ -237,12 +522,7 @@ fn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDo
|
||||
attrs: TiptapListItemAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content,
|
||||
}],
|
||||
content: list_item_content_from_block(block, content, document)?,
|
||||
}],
|
||||
}),
|
||||
EditorBlockType::Todo => Ok(TiptapNode::TaskList {
|
||||
@@ -254,21 +534,18 @@ fn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDo
|
||||
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,
|
||||
}],
|
||||
content: list_item_content_from_block(block, content, document)?,
|
||||
}],
|
||||
}),
|
||||
EditorBlockType::Quote => Ok(TiptapNode::Blockquote {
|
||||
attrs: TiptapBlockquoteAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
},
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
},
|
||||
content,
|
||||
}],
|
||||
@@ -277,9 +554,24 @@ fn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDo
|
||||
attrs: TiptapCodeBlockAttrs {
|
||||
language: block.props.language.clone(),
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
},
|
||||
content,
|
||||
}),
|
||||
EditorBlockType::Divider => Ok(TiptapNode::HorizontalRule {
|
||||
attrs: TiptapHorizontalRuleAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
}),
|
||||
EditorBlockType::Table => table_snapshot_from_props(&block.props).ok_or(
|
||||
EditorBlockDocumentTiptapError::InvalidDocument("表格块缺少 Tiptap 快照"),
|
||||
),
|
||||
EditorBlockType::Image => image_snapshot_from_props(&block.props).ok_or(
|
||||
EditorBlockDocumentTiptapError::InvalidDocument("图片块缺少 Tiptap 快照"),
|
||||
),
|
||||
EditorBlockType::Toc => toc_snapshot_from_props(&block.props).ok_or(
|
||||
EditorBlockDocumentTiptapError::InvalidDocument("目录块缺少 Tiptap 快照"),
|
||||
),
|
||||
_ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(
|
||||
"当前块型暂不支持导出到 Tiptap",
|
||||
)),
|
||||
@@ -295,7 +587,7 @@ fn node_to_block(
|
||||
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Paragraph,
|
||||
props: BlockProps::default(),
|
||||
props: props_with_text_align(attrs.text_align.clone()),
|
||||
content_nodes: text_nodes_from_tiptap(content)?,
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
@@ -304,7 +596,8 @@ fn node_to_block(
|
||||
block_type: EditorBlockType::Heading,
|
||||
props: BlockProps {
|
||||
heading_level: Some(attrs.level),
|
||||
..BlockProps::default()
|
||||
collapsed: attrs.collapsed,
|
||||
..props_with_text_align(attrs.text_align.clone())
|
||||
},
|
||||
content_nodes: text_nodes_from_tiptap(content)?,
|
||||
child_block_ids: vec![],
|
||||
@@ -325,7 +618,7 @@ fn node_to_block(
|
||||
TiptapNode::Blockquote { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Quote,
|
||||
props: BlockProps::default(),
|
||||
props: props_with_text_align(attrs.text_align.clone()),
|
||||
content_nodes: text_nodes_from_tiptap(extract_block_container_content(content)?)?,
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
@@ -334,11 +627,43 @@ fn node_to_block(
|
||||
block_type: EditorBlockType::CodeBlock,
|
||||
props: BlockProps {
|
||||
language: attrs.language.clone(),
|
||||
..BlockProps::default()
|
||||
..props_with_text_align(attrs.text_align.clone())
|
||||
},
|
||||
content_nodes: text_nodes_from_tiptap(content)?,
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
TiptapNode::HorizontalRule { attrs } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Divider,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: vec![],
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
TiptapNode::Table { attrs, content } => {
|
||||
let mut content_nodes = Vec::new();
|
||||
collect_table_text_nodes(content, &mut content_nodes);
|
||||
Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Table,
|
||||
props: props_with_table_snapshot(node),
|
||||
content_nodes,
|
||||
child_block_ids: vec![],
|
||||
})
|
||||
}
|
||||
TiptapNode::Image { attrs } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Image,
|
||||
props: props_with_image_snapshot(node),
|
||||
content_nodes: vec![],
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
TiptapNode::TocNode { attrs } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Toc,
|
||||
props: props_with_toc_snapshot(node),
|
||||
content_nodes: vec![],
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
_ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(
|
||||
"当前节点暂不支持导入为 EditorBlockDocument",
|
||||
)),
|
||||
@@ -359,6 +684,112 @@ fn list_node_to_block(
|
||||
})
|
||||
}
|
||||
|
||||
fn list_node_to_blocks(
|
||||
list_block_id: Option<&str>,
|
||||
block_type: EditorBlockType,
|
||||
content: &[TiptapNode],
|
||||
fallback_prefix: &str,
|
||||
) -> Result<(Vec<String>, Vec<EditorBlock>), EditorBlockDocumentTiptapError> {
|
||||
let mut root_ids = Vec::new();
|
||||
let mut blocks = Vec::new();
|
||||
let use_list_id_for_single_item = content.len() == 1;
|
||||
|
||||
for (index, item) in content.iter().enumerate() {
|
||||
let fallback_block_id = if use_list_id_for_single_item {
|
||||
list_block_id
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| fallback_prefix.to_string())
|
||||
} else {
|
||||
format!("{fallback_prefix}_item_{}", index + 1)
|
||||
};
|
||||
let (block, mut child_blocks) = list_item_node_to_block(
|
||||
item,
|
||||
block_type.clone(),
|
||||
&fallback_block_id,
|
||||
&format!("{fallback_prefix}_child_{}", index + 1),
|
||||
)?;
|
||||
root_ids.push(block.block_id.clone());
|
||||
blocks.push(block);
|
||||
blocks.append(&mut child_blocks);
|
||||
}
|
||||
|
||||
Ok((root_ids, blocks))
|
||||
}
|
||||
|
||||
fn list_item_node_to_block(
|
||||
item: &TiptapNode,
|
||||
block_type: EditorBlockType,
|
||||
fallback_block_id: &str,
|
||||
child_fallback_prefix: &str,
|
||||
) -> Result<(EditorBlock, Vec<EditorBlock>), EditorBlockDocumentTiptapError> {
|
||||
let (block_id, checked, content) = match item {
|
||||
TiptapNode::ListItem { attrs, content } => (
|
||||
attrs
|
||||
.block_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_block_id.to_string()),
|
||||
None,
|
||||
content,
|
||||
),
|
||||
TiptapNode::TaskItem { attrs, content } => (
|
||||
attrs
|
||||
.block_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_block_id.to_string()),
|
||||
Some(attrs.checked),
|
||||
content,
|
||||
),
|
||||
_ => {
|
||||
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
|
||||
"列表节点首子节点类型不正确",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let inline_content = extract_block_container_content(content)?;
|
||||
let mut child_block_ids = Vec::new();
|
||||
let mut child_blocks = Vec::new();
|
||||
for (index, child) in content.iter().enumerate() {
|
||||
let (child_root_ids, mut nested_blocks) = match child {
|
||||
TiptapNode::BulletList { attrs, content } => list_node_to_blocks(
|
||||
attrs.block_id.as_deref(),
|
||||
EditorBlockType::BulletListItem,
|
||||
content,
|
||||
&format!("{child_fallback_prefix}_bullet_{}", index + 1),
|
||||
)?,
|
||||
TiptapNode::OrderedList { attrs, content } => list_node_to_blocks(
|
||||
attrs.block_id.as_deref(),
|
||||
EditorBlockType::NumberedListItem,
|
||||
content,
|
||||
&format!("{child_fallback_prefix}_ordered_{}", index + 1),
|
||||
)?,
|
||||
TiptapNode::TaskList { attrs, content } => list_node_to_blocks(
|
||||
attrs.block_id.as_deref(),
|
||||
EditorBlockType::Todo,
|
||||
content,
|
||||
&format!("{child_fallback_prefix}_task_{}", index + 1),
|
||||
)?,
|
||||
_ => continue,
|
||||
};
|
||||
child_block_ids.extend(child_root_ids);
|
||||
child_blocks.append(&mut nested_blocks);
|
||||
}
|
||||
|
||||
Ok((
|
||||
EditorBlock {
|
||||
block_id,
|
||||
block_type,
|
||||
props: BlockProps {
|
||||
checked,
|
||||
..BlockProps::default()
|
||||
},
|
||||
content_nodes: text_nodes_from_tiptap(inline_content)?,
|
||||
child_block_ids,
|
||||
},
|
||||
child_blocks,
|
||||
))
|
||||
}
|
||||
|
||||
fn list_task_node_to_block(
|
||||
block_id: String,
|
||||
content: &[TiptapNode],
|
||||
@@ -492,3 +923,224 @@ fn tiptap_mark_to_text_mark(mark: &TiptapMark) -> Option<TextMark> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_horizontal_rule_to_divider_block() {
|
||||
let doc = TiptapNode::Doc {
|
||||
content: vec![TiptapNode::HorizontalRule {
|
||||
attrs: TiptapHorizontalRuleAttrs {
|
||||
block_id: Some("divider_1".into()),
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let parsed = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_1", &doc)
|
||||
.expect("horizontalRule should convert to divider");
|
||||
|
||||
assert_eq!(parsed.root_block_ids, vec!["divider_1"]);
|
||||
assert_eq!(parsed.blocks[0].block_type, EditorBlockType::Divider);
|
||||
assert!(parsed.blocks[0].content_nodes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_divider_block_to_horizontal_rule() {
|
||||
let document = EditorBlockDocument {
|
||||
document_id: "doc_1".into(),
|
||||
root_block_ids: vec!["divider_1".into()],
|
||||
blocks: vec![EditorBlock {
|
||||
block_id: "divider_1".into(),
|
||||
block_type: EditorBlockType::Divider,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: vec![],
|
||||
child_block_ids: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let tiptap = EditorBlockDocumentTiptapBridge::to_tiptap_doc(&document)
|
||||
.expect("divider should convert to horizontalRule");
|
||||
|
||||
assert_eq!(
|
||||
tiptap,
|
||||
TiptapNode::Doc {
|
||||
content: vec![TiptapNode::HorizontalRule {
|
||||
attrs: TiptapHorizontalRuleAttrs {
|
||||
block_id: Some("divider_1".into()),
|
||||
},
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_nested_bullet_list_to_child_blocks() {
|
||||
let doc = TiptapNode::Doc {
|
||||
content: vec![TiptapNode::BulletList {
|
||||
attrs: TiptapListAttrs {
|
||||
block_id: Some("list_root".into()),
|
||||
},
|
||||
content: vec![TiptapNode::ListItem {
|
||||
attrs: TiptapListItemAttrs {
|
||||
block_id: Some("parent".into()),
|
||||
},
|
||||
content: vec![
|
||||
TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs::default(),
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "E19 parent".into(),
|
||||
marks: vec![],
|
||||
}],
|
||||
},
|
||||
TiptapNode::BulletList {
|
||||
attrs: TiptapListAttrs::default(),
|
||||
content: vec![TiptapNode::ListItem {
|
||||
attrs: TiptapListItemAttrs {
|
||||
block_id: Some("child".into()),
|
||||
},
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs::default(),
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "E19 child".into(),
|
||||
marks: vec![],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
let parsed = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_1", &doc)
|
||||
.expect("nested list should convert to child blocks");
|
||||
|
||||
assert_eq!(parsed.root_block_ids, vec!["parent"]);
|
||||
assert_eq!(parsed.blocks.len(), 2);
|
||||
assert_eq!(parsed.blocks[0].block_id, "parent");
|
||||
assert_eq!(parsed.blocks[0].block_type, EditorBlockType::BulletListItem);
|
||||
assert_eq!(parsed.blocks[0].child_block_ids, vec!["child"]);
|
||||
assert_eq!(parsed.blocks[1].block_id, "child");
|
||||
assert_eq!(parsed.blocks[1].block_type, EditorBlockType::BulletListItem);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_child_blocks_to_nested_bullet_list() {
|
||||
let document = EditorBlockDocument {
|
||||
document_id: "doc_1".into(),
|
||||
root_block_ids: vec!["parent".into()],
|
||||
blocks: vec![
|
||||
EditorBlock {
|
||||
block_id: "parent".into(),
|
||||
block_type: EditorBlockType::BulletListItem,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: "E19 parent".into(),
|
||||
marks: vec![],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}],
|
||||
child_block_ids: vec!["child".into()],
|
||||
},
|
||||
EditorBlock {
|
||||
block_id: "child".into(),
|
||||
block_type: EditorBlockType::BulletListItem,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: "E19 child".into(),
|
||||
marks: vec![],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}],
|
||||
child_block_ids: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let tiptap = EditorBlockDocumentTiptapBridge::to_tiptap_doc(&document)
|
||||
.expect("child blocks should convert to nested list");
|
||||
|
||||
let TiptapNode::Doc { content } = tiptap else {
|
||||
panic!("expected doc");
|
||||
};
|
||||
let Some(TiptapNode::BulletList { content, .. }) = content.first() else {
|
||||
panic!("expected root bullet list");
|
||||
};
|
||||
let Some(TiptapNode::ListItem { content, .. }) = content.first() else {
|
||||
panic!("expected root list item");
|
||||
};
|
||||
assert!(content
|
||||
.iter()
|
||||
.any(|node| matches!(node, TiptapNode::BulletList { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_toc_node_snapshot() {
|
||||
let doc = TiptapNode::Doc {
|
||||
content: vec![TiptapNode::TocNode {
|
||||
attrs: TiptapTocNodeAttrs {
|
||||
top_offset: Some(0),
|
||||
max_show_count: Some(20),
|
||||
show_title: Some(true),
|
||||
block_id: Some("toc_1".into()),
|
||||
extra: BTreeMap::new(),
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let parsed = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_1", &doc)
|
||||
.expect("tocNode should convert to toc block");
|
||||
assert_eq!(parsed.root_block_ids, vec!["toc_1"]);
|
||||
assert_eq!(parsed.blocks[0].block_type, EditorBlockType::Toc);
|
||||
assert_eq!(
|
||||
parsed.blocks[0]
|
||||
.props
|
||||
.extra
|
||||
.get("tiptapTocNode")
|
||||
.and_then(|value| value.get("type"))
|
||||
.and_then(Value::as_str),
|
||||
Some("tocNode")
|
||||
);
|
||||
|
||||
let restored = EditorBlockDocumentTiptapBridge::to_tiptap_doc(&parsed)
|
||||
.expect("toc block should restore to tocNode");
|
||||
assert_eq!(restored, doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_paragraph_text_alignment() {
|
||||
let doc = TiptapNode::Doc {
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("align_1".into()),
|
||||
text_align: Some("center".into()),
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "E20 center".into(),
|
||||
marks: vec![],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
let parsed = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_1", &doc)
|
||||
.expect("textAlign should convert to block props");
|
||||
assert_eq!(
|
||||
parsed.blocks[0].props.extra.get("textAlign"),
|
||||
Some(&serde_json::json!("center"))
|
||||
);
|
||||
|
||||
let restored = EditorBlockDocumentTiptapBridge::to_tiptap_doc(&parsed)
|
||||
.expect("textAlign should restore to tiptap attrs");
|
||||
let TiptapNode::Doc { content } = restored else {
|
||||
panic!("expected doc");
|
||||
};
|
||||
let Some(TiptapNode::Paragraph { attrs, .. }) = content.first() else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
assert_eq!(attrs.text_align.as_deref(), Some("center"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user