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
+452 -19
View File
@@ -9150,6 +9150,10 @@ fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
"todo" | "task" => EditorBlockType::Todo,
"quote" | "blockquote" => EditorBlockType::Quote,
"code" | "code_block" | "code-block" => EditorBlockType::CodeBlock,
"divider" | "horizontal_rule" | "horizontalrule" => EditorBlockType::Divider,
"table" => EditorBlockType::Table,
"image" | "picture" => EditorBlockType::Image,
"toc" | "toc_node" | "tocnode" => EditorBlockType::Toc,
"page_reference" => EditorBlockType::PageReference,
"block_reference" => EditorBlockType::BlockReference,
_ => EditorBlockType::Paragraph,
@@ -9174,6 +9178,11 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
.and_then(Value::as_u64)
.and_then(|value| u8::try_from(value).ok())
.map(|value| value.clamp(1, 6));
props.collapsed = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("collapsed"))
.and_then(Value::as_bool);
}
if matches!(
normalize_editor_block_type_for_save(&raw_type),
@@ -9196,6 +9205,30 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc
.and_then(Value::as_str)
.map(ToOwned::to_owned);
}
if let Some(tiptap_table) = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("tiptapTable"))
.cloned()
{
props.extra.insert("tiptapTable".into(), tiptap_table);
}
if let Some(tiptap_image) = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("tiptapImage"))
.cloned()
{
props.extra.insert("tiptapImage".into(), tiptap_image);
}
if let Some(tiptap_toc) = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("tiptapTocNode").or_else(|| map.get("tiptapToc")))
.cloned()
{
props.extra.insert("tiptapTocNode".into(), tiptap_toc);
}
let text = read_trimmed_string_field(block, &["content"])
.filter(|value| !value.is_empty())
.unwrap_or_else(|| extract_inline_text(block));
@@ -9265,17 +9298,83 @@ fn normalize_save_editor_document(
}
fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
let mut props = serde_json::Map::new();
match block.block_type {
EditorBlockType::Heading => Some(json!({
"level": block.props.heading_level.unwrap_or(1),
})),
EditorBlockType::Todo => Some(json!({
"checked": block.props.checked.unwrap_or(false),
})),
EditorBlockType::CodeBlock => Some(json!({
"language": block.props.language,
})),
_ => None,
EditorBlockType::Heading => {
props.insert(
"level".into(),
json!(block.props.heading_level.unwrap_or(1)),
);
if let Some(collapsed) = block.props.collapsed {
props.insert("collapsed".into(), json!(collapsed));
}
}
EditorBlockType::Todo => {
props.insert(
"checked".into(),
json!(block.props.checked.unwrap_or(false)),
);
}
EditorBlockType::CodeBlock => {
props.insert("language".into(), json!(block.props.language));
}
EditorBlockType::Table => {
if let Some(tiptap_table) = block.props.extra.get("tiptapTable") {
props.insert("tiptapTable".into(), tiptap_table.clone());
}
}
EditorBlockType::Image => {
if let Some(tiptap_image) = block.props.extra.get("tiptapImage") {
props.insert("tiptapImage".into(), tiptap_image.clone());
if let Some(attrs) = tiptap_image.get("attrs").and_then(Value::as_object) {
for key in ["src", "alt", "title"] {
if let Some(value) = attrs.get(key) {
props.insert(key.into(), value.clone());
}
}
}
}
}
EditorBlockType::Toc => {
if let Some(tiptap_toc) = block.props.extra.get("tiptapTocNode") {
props.insert("tiptapTocNode".into(), tiptap_toc.clone());
}
}
_ => {}
}
if let Some(text_align) = block
.props
.extra
.get("textAlign")
.or_else(|| block.props.extra.get("text_align"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| matches!(*value, "left" | "center" | "right" | "justify"))
{
props.insert("textAlign".into(), json!(text_align));
}
if props.is_empty() {
None
} else {
Some(Value::Object(props))
}
}
fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str {
match block.block_type {
EditorBlockType::Paragraph => "paragraph",
EditorBlockType::Heading => "heading",
EditorBlockType::BulletListItem => "bullet_list_item",
EditorBlockType::NumberedListItem => "numbered_list_item",
EditorBlockType::Quote => "blockquote",
EditorBlockType::Todo => "todo",
EditorBlockType::CodeBlock => "code_block",
EditorBlockType::Divider => "divider",
EditorBlockType::Table => "table",
EditorBlockType::Image => "image",
EditorBlockType::Toc => "toc",
EditorBlockType::PageReference => "page_reference",
EditorBlockType::BlockReference => "block_reference",
}
}
@@ -9292,6 +9391,54 @@ fn legacy_text_from_editor_block(block: &EditorBlock) -> String {
}
fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value {
fn block_to_legacy_value(block: &EditorBlock, document: &EditorBlockDocument) -> Value {
let children = block
.child_block_ids
.iter()
.filter_map(|child_id| {
document
.blocks
.iter()
.find(|candidate| &candidate.block_id == child_id)
})
.map(|child| block_to_legacy_value(child, document))
.collect::<Vec<Value>>();
let mut value = json!({
"id": block.block_id,
"type": legacy_type_from_editor_block(block),
"props": legacy_props_from_editor_block(block),
"content": if matches!(block.block_type, EditorBlockType::Divider) {
Value::Array(Vec::new())
} else {
Value::String(legacy_text_from_editor_block(block))
},
});
if !children.is_empty() {
if let Value::Object(map) = &mut value {
map.insert("children".into(), Value::Array(children));
}
}
value
}
fn mark_block_tree_seen(
block: &EditorBlock,
document: &EditorBlockDocument,
seen: &mut std::collections::BTreeSet<String>,
) {
seen.insert(block.block_id.clone());
for child_id in &block.child_block_ids {
if let Some(child) = document
.blocks
.iter()
.find(|candidate| &candidate.block_id == child_id)
{
mark_block_tree_seen(child, document, seen);
}
}
}
let mut ordered = Vec::<&EditorBlock>::new();
let mut seen = std::collections::BTreeSet::<String>::new();
for root_block_id in &document.root_block_ids {
@@ -9300,7 +9447,7 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value
.iter()
.find(|block| &block.block_id == root_block_id)
{
seen.insert(block.block_id.clone());
mark_block_tree_seen(block, document, &mut seen);
ordered.push(block);
}
}
@@ -9313,14 +9460,7 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value
Value::Array(
ordered
.into_iter()
.map(|block| {
json!({
"id": block.block_id,
"type": block.block_type,
"props": legacy_props_from_editor_block(block),
"content": legacy_text_from_editor_block(block),
})
})
.map(|block| block_to_legacy_value(block, document))
.collect(),
)
}
@@ -10607,6 +10747,299 @@ mod tests {
);
}
#[test]
fn documents_save_command_plan_preserves_horizontal_rule_as_divider() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_divider".into(),
idempotency_key: Some("idem_save_divider".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 5,
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [
{
"type": "horizontalRule",
"attrs": {
"blockId": "divider_1"
}
}
]
},
"conflictDetectionKey": "doc_1:5"
}),
preflight_data: None,
reason: Some("保存分割线".into()),
refs: vec!["phase-e-divider".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save horizontalRule plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
Some(&json!("divider"))
);
assert_eq!(
plan.args_json.pointer("/content/0/type"),
Some(&json!("divider"))
);
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!([]))
);
}
#[test]
fn documents_save_command_plan_preserves_tiptap_image() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_image".into(),
idempotency_key: Some("idem_save_image".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 5,
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [{
"type": "image",
"attrs": {
"src": "/api/editor/image-placeholder.svg",
"alt": "E24 图片占位",
"title": "E24 图片"
}
}]
},
"conflictDetectionKey": "doc_1:5"
}),
preflight_data: None,
reason: Some("保存图片".into()),
refs: vec!["phase-e-image".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save image plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
Some(&json!("image"))
);
assert_eq!(
plan.args_json.pointer("/content/0/type"),
Some(&json!("image"))
);
assert_eq!(
plan.args_json.pointer("/content/0/props/tiptapImage/type"),
Some(&json!("image"))
);
assert_eq!(
plan.args_json.pointer("/content/0/props/src"),
Some(&json!("/api/editor/image-placeholder.svg"))
);
}
#[test]
fn documents_save_command_plan_preserves_tiptap_table() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_table".into(),
idempotency_key: Some("idem_save_table".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 5,
"content": [],
"tiptapDocument": {
"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" }] }]
}]
}]
}]
},
"conflictDetectionKey": "doc_1:5"
}),
preflight_data: None,
reason: Some("保存简单表格".into()),
refs: vec!["phase-e-table".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save table plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
Some(&json!("table"))
);
assert_eq!(
plan.args_json.pointer("/content/0/type"),
Some(&json!("table"))
);
assert_eq!(
plan.args_json.pointer("/content/0/props/tiptapTable/type"),
Some(&json!("table"))
);
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!("A1"))
);
}
#[test]
fn documents_save_command_plan_preserves_nested_list_children() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_nested_list".into(),
idempotency_key: Some("idem_save_nested_list".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "rust-web".into(),
client: "mnote-web".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 5,
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [{
"type": "bulletList",
"content": [{
"type": "listItem",
"attrs": { "blockId": "parent" },
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "E19 parent" }] },
{ "type": "bulletList", "content": [{
"type": "listItem",
"attrs": { "blockId": "child" },
"content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "E19 child" }] }]
}]}
]
}]
}]
},
"conflictDetectionKey": "doc_1:5"
}),
preflight_data: None,
reason: Some("保存嵌套列表".into()),
refs: vec!["phase-e-indent".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save nested list plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(
plan.args_json.pointer("/editorDocument/rootBlockIds"),
Some(&json!(["parent"]))
);
assert_eq!(
plan.args_json
.pointer("/editorDocument/blocks/0/childBlockIds"),
Some(&json!(["child"]))
);
assert_eq!(
plan.args_json.pointer("/content/0/children/0/type"),
Some(&json!("bullet_list_item"))
);
assert_eq!(
plan.args_json.pointer("/content/0/children/0/content"),
Some(&json!("E19 child"))
);
assert_eq!(plan.args_json.pointer("/content/1"), None);
}
#[test]
fn page_body_save_command_plan_uses_canonical_page_command() {
let plan = execute_runtime_input(RuntimeInput::Command {
@@ -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"))
);
}
+4
View File
@@ -52,6 +52,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/leptos-tiptap-runtime/{*asset_path}",
get(web_shell::leptos_tiptap_asset),
)
.route(
"/api/editor/image-placeholder.svg",
get(web_shell::editor_image_placeholder_asset),
)
.route("/api/search/documents", post(search::documents))
.route("/api/gateway/health", get(gateway::gateway_health))
.route("/api/runtime/config", get(session::runtime_config))
+125 -11
View File
@@ -311,26 +311,93 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
const text = flattenText(block?.content);
const content = text ? [{ type: 'text', text }] : [];
const textAlign = typeof block?.props?.textAlign === 'string'
? block.props.textAlign
: typeof block?.props?.text_align === 'string'
? block.props.text_align
: undefined;
const withTextAlign = (attrs = {}) => textAlign ? { ...attrs, textAlign } : attrs;
const nestedChildren = Array.isArray(block?.children)
? block.children.map(legacyBlockToTiptap).filter(Boolean)
: [];
const withListChildren = (itemType, listType, attrs = {}) => ({
type: listType,
content: [{
type: itemType,
attrs,
content: [
{ type: 'paragraph', content },
...nestedChildren,
],
}],
});
if (type === 'heading') {
const level = Number(block?.props?.level || block?.level || 1) || 1;
return { type: 'heading', attrs: { level: Math.max(1, Math.min(6, level)) }, content };
const collapsed = typeof block?.props?.collapsed === 'boolean' ? { collapsed: block.props.collapsed } : {};
return { type: 'heading', attrs: withTextAlign({ level: Math.max(1, Math.min(6, level)), ...collapsed }), content };
}
if (type === 'bulletListItem' || type === 'bullet_list_item') {
return { type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content }] }] };
return withListChildren('listItem', 'bulletList');
}
if (type === 'numberedListItem' || type === 'numbered_list_item') {
return { type: 'orderedList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content }] }] };
return withListChildren('listItem', 'orderedList');
}
if (type === 'checkListItem' || type === 'advancedTodo' || type === 'todo') {
return { type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: Boolean(block?.props?.checked) }, content: [{ type: 'paragraph', content }] }] };
return withListChildren('taskItem', 'taskList', { checked: Boolean(block?.props?.checked) });
}
if (type === 'quote' || type === 'blockquote') {
return { type: 'blockquote', content: [{ type: 'paragraph', content }] };
return { type: 'blockquote', attrs: withTextAlign(), content: [{ type: 'paragraph', attrs: withTextAlign(), content }] };
}
if (type === 'codeBlock') {
return { type: 'codeBlock', attrs: { language: block?.props?.language || null }, content };
if (type === 'codeBlock' || type === 'code_block') {
return { type: 'codeBlock', attrs: withTextAlign({ language: block?.props?.language || null }), content };
}
return { type: 'paragraph', content };
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') {
return { type: 'horizontalRule' };
}
if (type === 'table') {
const tableSnapshot = block?.props?.tiptapTable;
if (tableSnapshot && typeof tableSnapshot === 'object' && tableSnapshot.type === 'table') {
return tableSnapshot;
}
return {
type: 'table',
content: [{
type: 'tableRow',
content: [{
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [{ type: 'paragraph', content }],
}],
}],
};
}
if (type === 'toc' || type === 'tocNode' || type === 'toc_node') {
const tocSnapshot = block?.props?.tiptapTocNode || block?.props?.tiptapToc;
if (tocSnapshot && typeof tocSnapshot === 'object' && tocSnapshot.type === 'tocNode') {
return tocSnapshot;
}
return {
type: 'tocNode',
attrs: {
topOffset: Number(block?.props?.topOffset || block?.props?.top_offset || 0) || 0,
maxShowCount: Number(block?.props?.maxShowCount || block?.props?.max_show_count || 20) || 20,
showTitle: block?.props?.showTitle !== false,
},
};
}
if (type === 'image') {
const imageSnapshot = block?.props?.tiptapImage;
if (imageSnapshot && typeof imageSnapshot === 'object' && imageSnapshot.type === 'image') {
return imageSnapshot;
}
const attrs = {
src: String(block?.props?.src || block?.src || ''),
alt: block?.props?.alt || block?.alt || null,
title: block?.props?.title || block?.title || null,
};
return attrs.src ? { type: 'image', attrs } : null;
}
return { type: 'paragraph', attrs: withTextAlign(), content };
};
const textToTiptapDocument = (text) => ({
@@ -371,9 +438,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const pageBody = aggregate.body || {};
const permissions = aggregate.head?.permissions || {};
const conflictDetectionKey = typeof pageBody.conflictDetectionKey === 'string'
? pageBody.conflictDetectionKey
: typeof pageBody.conflict_detection_key === 'string'
? pageBody.conflict_detection_key
: null;
const revisionFromConflictKey = (value) => {
const match = String(value || '').match(/:(\d+)$/);
return match ? Number(match[1]) : null;
};
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const editorMeta = {
revision: Number.isInteger(pageBody.revision) ? pageBody.revision : null,
conflictDetectionKey: typeof pageBody.conflictDetectionKey === 'string' ? pageBody.conflictDetectionKey : null,
revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision,
conflictDetectionKey,
};
const mountOptions = {
documentId: bootstrap.documentId,
@@ -389,8 +467,26 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
let saveTimer = 0;
let lastSavedSerialized = '';
const normalizeBridgeValue = (value) => {
if (value instanceof Map) {
const out = {};
for (const [key, item] of value.entries()) {
out[key] = normalizeBridgeValue(item);
}
return out;
}
if (Array.isArray(value)) {
return value.map(normalizeBridgeValue);
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, normalizeBridgeValue(item)]),
);
}
return value;
};
const normalizeEnvelopePayload = (event) => {
const detail = event?.detail;
const detail = normalizeBridgeValue(event?.detail);
if (!detail || typeof detail !== 'object') return null;
const payload = detail.payload && typeof detail.payload === 'object' ? detail.payload : detail;
return payload && typeof payload === 'object' ? payload : null;
@@ -512,6 +608,24 @@ fn runtime_asset_content_type(asset_path: &str) -> &'static str {
}
}
pub async fn editor_image_placeholder_asset() -> Response {
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360" role="img" aria-label="E24 image placeholder">
<rect width="640" height="360" rx="18" fill="#f3f4f6"/>
<rect x="42" y="42" width="556" height="276" rx="14" fill="#ffffff" stroke="#d1d5db" stroke-width="2"/>
<circle cx="168" cy="132" r="34" fill="#93c5fd"/>
<path d="M98 278 246 174 340 242 410 192 542 278Z" fill="#86efac"/>
<path d="M98 278 246 174 340 242 410 192 542 278" fill="none" stroke="#16a34a" stroke-width="8" stroke-linejoin="round"/>
<text x="320" y="322" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#374151">E24 Image</text>
</svg>"##;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/svg+xml; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(SVG))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn leptos_tiptap_manifest() -> Response {
let manifest = json!({
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",