Checkpoint current workspace
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user