Checkpoint current workspace

This commit is contained in:
lix-2026
2026-05-02 06:25:26 +08:00
parent 015ad5bedd
commit 98b6360595
88 changed files with 40217 additions and 178 deletions
@@ -12,6 +12,10 @@ pub enum EditorBlockType {
Quote,
Todo,
CodeBlock,
Divider,
Table,
Image,
Toc,
PageReference,
BlockReference,
}
+679 -27
View File
@@ -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"));
}
}
@@ -149,6 +149,7 @@ fn tiptap_import_preserves_explicit_block_ids_and_marks() {
TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some("p-1".into()),
text_align: None,
},
content: vec![TiptapNode::Text {
text: "Hello".into(),
@@ -161,6 +162,8 @@ fn tiptap_import_preserves_explicit_block_ids_and_marks() {
attrs: TiptapHeadingAttrs {
level: 3,
block_id: Some("h-1".into()),
text_align: None,
collapsed: None,
},
content: vec![TiptapNode::Text {
text: "H".into(),
@@ -179,6 +182,7 @@ fn tiptap_import_preserves_explicit_block_ids_and_marks() {
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some("t-1".into()),
text_align: None,
},
content: vec![TiptapNode::Text {
text: "todo".into(),
@@ -191,6 +195,7 @@ fn tiptap_import_preserves_explicit_block_ids_and_marks() {
attrs: TiptapCodeBlockAttrs {
language: Some("ts".into()),
block_id: Some("c-1".into()),
text_align: None,
},
content: vec![TiptapNode::Text {
text: "let x = 1;".into(),
@@ -214,3 +219,119 @@ fn tiptap_import_preserves_explicit_block_ids_and_marks() {
other => panic!("unexpected payload: {other:?}"),
}
}
#[test]
fn tiptap_heading_round_trip_preserves_collapsed_attr() {
let doc: TiptapNode = serde_json::from_value(serde_json::json!({
"type": "doc",
"content": [{
"type": "heading",
"attrs": {
"level": 1,
"blockId": "folded-h1",
"collapsed": true
},
"content": [{ "type": "text", "text": "折叠主标题" }]
}]
}))
.expect("heading JSON should parse");
let imported = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_folded", &doc)
.expect("heading should import");
assert_eq!(imported.blocks[0].props.heading_level, Some(1));
assert_eq!(imported.blocks[0].props.collapsed, Some(true));
let exported =
EditorBlockDocumentTiptapBridge::to_tiptap_doc(&imported).expect("heading should export");
let exported_value = serde_json::to_value(exported).expect("exported doc should serialize");
assert_eq!(
exported_value.pointer("/content/0/attrs/collapsed"),
Some(&serde_json::json!(true))
);
}
#[test]
fn tiptap_image_round_trip_preserves_image_node() {
let doc: TiptapNode = serde_json::from_value(serde_json::json!({
"type": "doc",
"content": [{
"type": "image",
"attrs": {
"src": "/api/editor/image-placeholder.svg",
"alt": "E24 图片占位",
"title": "E24 图片"
}
}]
}))
.expect("image JSON should parse");
let imported = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_image", &doc)
.expect("image should import");
assert_eq!(imported.blocks[0].block_type, EditorBlockType::Image);
let exported =
EditorBlockDocumentTiptapBridge::to_tiptap_doc(&imported).expect("image should export");
let exported_value = serde_json::to_value(exported).expect("exported doc should serialize");
assert_eq!(
exported_value.pointer("/content/0/type"),
Some(&serde_json::json!("image"))
);
assert_eq!(
exported_value.pointer("/content/0/attrs/src"),
Some(&serde_json::json!("/api/editor/image-placeholder.svg"))
);
}
#[test]
fn tiptap_table_round_trip_preserves_table_node() {
let doc: TiptapNode = serde_json::from_value(serde_json::json!({
"type": "doc",
"content": [{
"type": "table",
"attrs": { "blockId": "table-1" },
"content": [{
"type": "tableRow",
"content": [{
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1, "colwidth": null },
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "A1" }]
}]
}, {
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1, "colwidth": null },
"content": [{ "type": "paragraph" }]
}]
}, {
"type": "tableRow",
"content": [{
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1, "colwidth": null },
"content": [{ "type": "paragraph" }]
}, {
"type": "tableCell",
"attrs": { "colspan": 1, "rowspan": 1, "colwidth": null },
"content": [{ "type": "paragraph" }]
}]
}]
}]
}))
.expect("table JSON should parse");
let imported = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_table", &doc)
.expect("table should import");
assert_eq!(imported.blocks[0].block_type, EditorBlockType::Table);
let exported =
EditorBlockDocumentTiptapBridge::to_tiptap_doc(&imported).expect("table should export");
let exported_value = serde_json::to_value(exported).expect("exported doc should serialize");
assert_eq!(
exported_value.pointer("/content/0/type"),
Some(&serde_json::json!("table"))
);
assert_eq!(
exported_value.pointer("/content/0/content/0/content/0/content/0/content/0/text"),
Some(&serde_json::json!("A1"))
);
}