主编辑区改造准备
This commit is contained in:
@@ -4,11 +4,14 @@ use adapter_onlyoffice::{
|
||||
OnlyOfficeProxyPreparationInput, OnlyOfficeSessionResolveInput,
|
||||
};
|
||||
use core_domain::Timestamp;
|
||||
use core_protocol::editor::{EditorBlockDocumentTiptapBridge, TiptapNode};
|
||||
use core_protocol::{
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload, CommandEnvelope,
|
||||
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
|
||||
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
|
||||
DocumentReadStats, DocumentReadSubtree, EmbedBlock, GetBlock, GetBridgeCommand,
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload, BlockProps,
|
||||
CommandEnvelope, ContentNode, ContentNodePayload, DocumentContentResult,
|
||||
DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta,
|
||||
DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats,
|
||||
DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand,
|
||||
EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand,
|
||||
GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge,
|
||||
KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge,
|
||||
KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
||||
@@ -486,7 +489,12 @@ struct DocumentSaveCommandPayload {
|
||||
document_id: String,
|
||||
workspace_id: Option<String>,
|
||||
revision: Option<u64>,
|
||||
#[serde(default)]
|
||||
editor_document: Option<Value>,
|
||||
#[serde(default)]
|
||||
content: Value,
|
||||
#[serde(default)]
|
||||
tiptap_document: Option<Value>,
|
||||
conflict_detection_key: Option<String>,
|
||||
}
|
||||
|
||||
@@ -673,6 +681,7 @@ struct RuntimeInsertSpec {
|
||||
struct InsertBlocksResult {
|
||||
inserted: Vec<String>,
|
||||
blocks: Vec<Value>,
|
||||
editor_commands: Vec<EditorCommand>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
@@ -1567,10 +1576,17 @@ fn execute_tool_result(
|
||||
let after_block_id = read_optional_string_field(&args, "afterBlockId");
|
||||
let before_block_id = read_optional_string_field(&args, "beforeBlockId");
|
||||
let result = apply_insert_blocks(blocks, after_block_id, before_block_id, specs)?;
|
||||
let editor_command_source = if result.editor_commands.is_empty() {
|
||||
"legacy_snapshot_fallback"
|
||||
} else {
|
||||
"rust_editor_command"
|
||||
};
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"source": infer_tool_source(&data),
|
||||
"inserted": result.inserted,
|
||||
"editorCommandSource": editor_command_source,
|
||||
"editorCommands": result.editor_commands,
|
||||
"data": result.blocks,
|
||||
}))
|
||||
}
|
||||
@@ -1580,12 +1596,20 @@ fn execute_tool_result(
|
||||
let text = read_required_string_field(&args, "text")?;
|
||||
let mode =
|
||||
read_optional_string_field(&args, "mode").unwrap_or_else(|| "replace".into());
|
||||
let editor_commands = build_replace_editor_commands(&blocks, &block_id, &text, &mode);
|
||||
let result = apply_replace_range(blocks, &block_id, &text, &mode)?;
|
||||
let editor_command_source = if editor_commands.is_empty() {
|
||||
"legacy_snapshot_fallback"
|
||||
} else {
|
||||
"rust_editor_command"
|
||||
};
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"source": infer_tool_source(&data),
|
||||
"blockId": block_id,
|
||||
"mode": mode,
|
||||
"editorCommandSource": editor_command_source,
|
||||
"editorCommands": editor_commands,
|
||||
"data": result,
|
||||
}))
|
||||
}
|
||||
@@ -3257,6 +3281,13 @@ fn apply_insert_blocks(
|
||||
.iter()
|
||||
.map(build_block_from_spec)
|
||||
.collect::<Vec<Value>>();
|
||||
let editor_commands = build_insert_editor_commands(
|
||||
&blocks,
|
||||
after_block_id.as_deref(),
|
||||
before_block_id.as_deref(),
|
||||
&created,
|
||||
&specs,
|
||||
);
|
||||
let inserted = created
|
||||
.iter()
|
||||
.filter_map(|block| read_string_value(block, "id"))
|
||||
@@ -3280,7 +3311,11 @@ fn apply_insert_blocks(
|
||||
None => blocks.extend(created),
|
||||
}
|
||||
|
||||
Ok(InsertBlocksResult { inserted, blocks })
|
||||
Ok(InsertBlocksResult {
|
||||
inserted,
|
||||
blocks,
|
||||
editor_commands,
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_blocks_at_target(
|
||||
@@ -3328,6 +3363,146 @@ fn build_block_from_spec(spec: &RuntimeInsertSpec) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_text_content_nodes(text: &str) -> Vec<ContentNode> {
|
||||
if text.trim().is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: text.to_string(),
|
||||
marks: vec![],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn editor_block_type_from_spec(spec: &RuntimeInsertSpec) -> EditorBlockType {
|
||||
match spec.block_type.as_str() {
|
||||
"heading" => EditorBlockType::Heading,
|
||||
_ => EditorBlockType::Paragraph,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_editor_block_from_spec(spec: &RuntimeInsertSpec, block_id: &str) -> EditorBlock {
|
||||
let mut props = BlockProps::default();
|
||||
if matches!(editor_block_type_from_spec(spec), EditorBlockType::Heading) {
|
||||
props.heading_level = Some(spec.level.clamp(1, 5) as u8);
|
||||
}
|
||||
EditorBlock {
|
||||
block_id: block_id.to_string(),
|
||||
block_type: editor_block_type_from_spec(spec),
|
||||
props,
|
||||
content_nodes: build_text_content_nodes(&spec.text),
|
||||
child_block_ids: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_block_order(blocks: &[Value], order: &mut Vec<String>) {
|
||||
for block in blocks {
|
||||
if let Some(block_id) = read_string_value(block, "id") {
|
||||
order.push(block_id);
|
||||
}
|
||||
if let Some(children) = block
|
||||
.as_object()
|
||||
.and_then(|map| map.get("children"))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
collect_block_order(children, order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_insert_anchor(
|
||||
blocks: &[Value],
|
||||
after_block_id: Option<&str>,
|
||||
before_block_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let mut order = Vec::new();
|
||||
collect_block_order(blocks, &mut order);
|
||||
if let Some(after_block_id) = after_block_id {
|
||||
if order.iter().any(|item| item == after_block_id) {
|
||||
return Some(after_block_id.to_string());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if let Some(before_block_id) = before_block_id {
|
||||
let index = order.iter().position(|item| item == before_block_id)?;
|
||||
if index == 0 {
|
||||
return None;
|
||||
}
|
||||
return order.get(index - 1).cloned();
|
||||
}
|
||||
order.last().cloned()
|
||||
}
|
||||
|
||||
fn build_insert_editor_commands(
|
||||
blocks: &[Value],
|
||||
after_block_id: Option<&str>,
|
||||
before_block_id: Option<&str>,
|
||||
created: &[Value],
|
||||
specs: &[RuntimeInsertSpec],
|
||||
) -> Vec<EditorCommand> {
|
||||
let mut anchor = resolve_insert_anchor(blocks, after_block_id, before_block_id);
|
||||
if anchor.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
for (created_block, spec) in created.iter().zip(specs.iter()) {
|
||||
let Some(block_id) = read_string_value(created_block, "id") else {
|
||||
continue;
|
||||
};
|
||||
let Some(after_block_id) = anchor.clone() else {
|
||||
break;
|
||||
};
|
||||
commands.push(EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
|
||||
after_block_id,
|
||||
block: build_editor_block_from_spec(spec, &block_id),
|
||||
}));
|
||||
anchor = Some(block_id);
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
fn find_block_text(blocks: &[Value], block_id: &str) -> Option<String> {
|
||||
for block in blocks {
|
||||
if read_string_value(block, "id").as_deref() == Some(block_id) {
|
||||
return Some(extract_inline_text(block));
|
||||
}
|
||||
if let Some(children) = block
|
||||
.as_object()
|
||||
.and_then(|map| map.get("children"))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
if let Some(found) = find_block_text(children, block_id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn build_replace_editor_commands(
|
||||
blocks: &[Value],
|
||||
block_id: &str,
|
||||
text: &str,
|
||||
mode: &str,
|
||||
) -> Vec<EditorCommand> {
|
||||
let Some(previous) = find_block_text(blocks, block_id) else {
|
||||
return vec![];
|
||||
};
|
||||
let next_text = match mode {
|
||||
"append" => format!("{previous}{text}"),
|
||||
"prepend" => format!("{text}{previous}"),
|
||||
_ => text.to_string(),
|
||||
};
|
||||
vec![EditorCommand::ReplaceBlock(EditorReplaceBlock {
|
||||
block_id: block_id.to_string(),
|
||||
block_type: None,
|
||||
props: None,
|
||||
content_nodes: Some(build_text_content_nodes(&next_text)),
|
||||
})]
|
||||
}
|
||||
|
||||
fn apply_replace_range(
|
||||
mut blocks: Vec<Value>,
|
||||
block_id: &str,
|
||||
@@ -5335,6 +5510,8 @@ fn execute_command(
|
||||
}
|
||||
"documents.save" => {
|
||||
let payload: DocumentSaveCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
let editor_document = normalize_save_editor_document(&payload)?;
|
||||
let canonical_content = legacy_content_from_editor_document(&editor_document);
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.save".into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
@@ -5346,7 +5523,7 @@ fn execute_command(
|
||||
page_id: payload.document_id.clone(),
|
||||
workspace_id: payload.workspace_id.clone(),
|
||||
revision: payload.revision,
|
||||
content_json: serde_json::to_string(&payload.content).map_err(|error| {
|
||||
content_json: serde_json::to_string(&canonical_content).map_err(|error| {
|
||||
BridgeError::validation(format!(
|
||||
"documents.save content 序列化失败: {error}"
|
||||
))
|
||||
@@ -5371,7 +5548,9 @@ fn execute_command(
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"id": payload.document_id,
|
||||
"content": payload.content,
|
||||
"content": canonical_content,
|
||||
"editorDocument": editor_document,
|
||||
"tiptapDocument": payload.tiptap_document,
|
||||
"expectedRevision": payload.revision,
|
||||
"conflictDetectionKey": payload.conflict_detection_key,
|
||||
}),
|
||||
@@ -5919,6 +6098,191 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
|
||||
match raw_type {
|
||||
"heading" => EditorBlockType::Heading,
|
||||
"bullet_list_item" | "bullet_list" | "bullet-list" => EditorBlockType::BulletListItem,
|
||||
"numbered_list_item" | "ordered_list" | "ordered-list" => EditorBlockType::NumberedListItem,
|
||||
"todo" | "task" => EditorBlockType::Todo,
|
||||
"quote" | "blockquote" => EditorBlockType::Quote,
|
||||
"code" | "code_block" | "code-block" => EditorBlockType::CodeBlock,
|
||||
"page_reference" => EditorBlockType::PageReference,
|
||||
"block_reference" => EditorBlockType::BlockReference,
|
||||
_ => EditorBlockType::Paragraph,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBlock {
|
||||
let block_id = read_trimmed_string_field(block, &["blockId", "id"])
|
||||
.unwrap_or_else(|| format!("block-{}", index + 1));
|
||||
let raw_type = read_trimmed_string_field(block, &["blockType", "type"])
|
||||
.unwrap_or_else(|| "paragraph".into())
|
||||
.to_lowercase();
|
||||
let mut props = BlockProps::default();
|
||||
if matches!(
|
||||
normalize_editor_block_type_for_save(&raw_type),
|
||||
EditorBlockType::Heading
|
||||
) {
|
||||
props.heading_level = block
|
||||
.get("props")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|map| map.get("level").or_else(|| map.get("headingLevel")))
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u8::try_from(value).ok())
|
||||
.map(|value| value.clamp(1, 6));
|
||||
}
|
||||
if matches!(
|
||||
normalize_editor_block_type_for_save(&raw_type),
|
||||
EditorBlockType::Todo
|
||||
) {
|
||||
props.checked = block
|
||||
.get("props")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|map| map.get("checked"))
|
||||
.and_then(Value::as_bool);
|
||||
}
|
||||
if matches!(
|
||||
normalize_editor_block_type_for_save(&raw_type),
|
||||
EditorBlockType::CodeBlock
|
||||
) {
|
||||
props.language = block
|
||||
.get("props")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|map| map.get("language"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
}
|
||||
let text = read_trimmed_string_field(block, &["content"])
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| extract_inline_text(block));
|
||||
|
||||
EditorBlock {
|
||||
block_id,
|
||||
block_type: normalize_editor_block_type_for_save(&raw_type),
|
||||
props,
|
||||
content_nodes: build_text_content_nodes(&text),
|
||||
child_block_ids: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_document_from_legacy_content(document_id: &str, content: &Value) -> EditorBlockDocument {
|
||||
let blocks = normalize_blocks_from_value(content)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, block)| normalize_editor_block_from_legacy(block, index))
|
||||
.collect::<Vec<EditorBlock>>();
|
||||
let root_block_ids = blocks
|
||||
.iter()
|
||||
.map(|block| block.block_id.clone())
|
||||
.collect::<Vec<String>>();
|
||||
EditorBlockDocument {
|
||||
document_id: document_id.to_string(),
|
||||
root_block_ids,
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_save_editor_document(
|
||||
payload: &DocumentSaveCommandPayload,
|
||||
) -> Result<EditorBlockDocument, BridgeError> {
|
||||
if let Some(editor_document) = payload.editor_document.clone() {
|
||||
let mut parsed =
|
||||
serde_json::from_value::<EditorBlockDocument>(editor_document).map_err(|error| {
|
||||
BridgeError::validation(format!("documents.save editorDocument 非法: {error}"))
|
||||
})?;
|
||||
if parsed.document_id.trim().is_empty() {
|
||||
parsed.document_id = payload.document_id.clone();
|
||||
}
|
||||
if parsed.root_block_ids.is_empty() {
|
||||
parsed.root_block_ids = parsed
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| block.block_id.clone())
|
||||
.collect();
|
||||
}
|
||||
return Ok(parsed);
|
||||
}
|
||||
if let Some(tiptap_document) = payload.tiptap_document.clone() {
|
||||
let parsed = serde_json::from_value::<TiptapNode>(tiptap_document).map_err(|error| {
|
||||
BridgeError::validation(format!("documents.save tiptapDocument 非法: {error}"))
|
||||
})?;
|
||||
return EditorBlockDocumentTiptapBridge::from_tiptap_doc(
|
||||
payload.document_id.clone(),
|
||||
&parsed,
|
||||
)
|
||||
.map_err(|error| {
|
||||
BridgeError::validation(format!(
|
||||
"documents.save tiptap -> editorDocument 失败: {error:?}"
|
||||
))
|
||||
});
|
||||
}
|
||||
Ok(editor_document_from_legacy_content(
|
||||
&payload.document_id,
|
||||
&payload.content,
|
||||
))
|
||||
}
|
||||
|
||||
fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_text_from_editor_block(block: &EditorBlock) -> String {
|
||||
block
|
||||
.content_nodes
|
||||
.iter()
|
||||
.filter_map(|node| match &node.payload {
|
||||
ContentNodePayload::Text { text, .. } => Some(text.as_str()),
|
||||
ContentNodePayload::HardBreak => Some("\n"),
|
||||
ContentNodePayload::ReferenceToken { token } => token.label.as_deref(),
|
||||
})
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value {
|
||||
let mut ordered = Vec::<&EditorBlock>::new();
|
||||
let mut seen = std::collections::BTreeSet::<String>::new();
|
||||
for root_block_id in &document.root_block_ids {
|
||||
if let Some(block) = document
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|block| &block.block_id == root_block_id)
|
||||
{
|
||||
seen.insert(block.block_id.clone());
|
||||
ordered.push(block);
|
||||
}
|
||||
}
|
||||
for block in &document.blocks {
|
||||
if seen.insert(block.block_id.clone()) {
|
||||
ordered.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn to_bridge_context(context: RuntimeBridgeContextWire) -> BridgeContext {
|
||||
BridgeContext {
|
||||
deployment_id: context.deployment_id,
|
||||
@@ -6722,6 +7086,25 @@ mod tests {
|
||||
"source": "client",
|
||||
"blockId": "block_1",
|
||||
"mode": "replace",
|
||||
"editorCommandSource": "rust_editor_command",
|
||||
"editorCommands": [
|
||||
{
|
||||
"kind": "replace_block",
|
||||
"blockId": "block_1",
|
||||
"blockType": null,
|
||||
"props": null,
|
||||
"contentNodes": [
|
||||
{
|
||||
"payload": {
|
||||
"type": "text",
|
||||
"text": "新的正文",
|
||||
"marks": []
|
||||
},
|
||||
"attrs": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"id": "block_1",
|
||||
@@ -6734,6 +7117,148 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_insert_blocks_tool_emits_editor_commands() {
|
||||
let result = execute_runtime_query(RuntimeInput::Tool {
|
||||
context: demo_context(),
|
||||
tool: RuntimeToolInvocationWire {
|
||||
tool: "doc_insert_blocks".into(),
|
||||
kind: "command".into(),
|
||||
mode: Some("result".into()),
|
||||
args_json: json!({
|
||||
"afterBlockId": "block_1",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "heading",
|
||||
"level": 2,
|
||||
"text": "Rust 命令插入标题"
|
||||
}
|
||||
]
|
||||
}),
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: Some("block_1".into()),
|
||||
}),
|
||||
reason: Some("在块后插入标题".into()),
|
||||
refs: vec!["task-054".into()],
|
||||
},
|
||||
data: Some(json!({
|
||||
"source": "client",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "block_1",
|
||||
"type": "paragraph",
|
||||
"content": [{"type":"text","text":"旧内容"}],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
})),
|
||||
})
|
||||
.expect("tool result should build");
|
||||
|
||||
assert_eq!(result.get("ok"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
result.get("editorCommandSource"),
|
||||
Some(&json!("rust_editor_command"))
|
||||
);
|
||||
assert_eq!(
|
||||
result.pointer("/editorCommands/0/kind"),
|
||||
Some(&json!("insert_block_after"))
|
||||
);
|
||||
assert_eq!(
|
||||
result.pointer("/editorCommands/0/afterBlockId"),
|
||||
Some(&json!("block_1"))
|
||||
);
|
||||
assert_eq!(
|
||||
result.pointer("/editorCommands/0/block/blockType"),
|
||||
Some(&json!("heading"))
|
||||
);
|
||||
assert_eq!(
|
||||
result.pointer("/editorCommands/0/block/props/headingLevel"),
|
||||
Some(&json!(2))
|
||||
);
|
||||
assert_eq!(
|
||||
result.pointer("/editorCommands/0/block/contentNodes/0/payload/text"),
|
||||
Some(&json!("Rust 命令插入标题"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_save_command_plan_normalizes_tiptap_into_rust_truth() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "documents.save".into(),
|
||||
command_id: "cmd_save_1".into(),
|
||||
idempotency_key: Some("idem_save".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".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": 4,
|
||||
"content": [],
|
||||
"tiptapDocument": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"blockId": "p_1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "来自 Rust tiptap 归一化"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"conflictDetectionKey": "doc_1:4"
|
||||
}),
|
||||
reason: Some("保存正文".into()),
|
||||
refs: vec!["task-055".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("documents.save plan should build");
|
||||
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
|
||||
assert_eq!(plan.function_name, "documents:updateContent");
|
||||
assert_eq!(plan.args_json.get("id"), Some(&json!("doc_1")));
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/editorDocument/rootBlockIds/0"),
|
||||
Some(&json!("p_1"))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
|
||||
Some(&json!("paragraph"))
|
||||
);
|
||||
assert_eq!(plan.args_json.pointer("/content/0/id"), Some(&json!("p_1")));
|
||||
assert_eq!(
|
||||
plan.args_json.pointer("/content/0/content"),
|
||||
Some(&json!("来自 Rust tiptap 归一化"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_move_command_plan_maps_to_blocks_move() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod command;
|
||||
pub mod markdown;
|
||||
pub mod model;
|
||||
pub mod tiptap;
|
||||
|
||||
pub use command::{
|
||||
EditorAttachReferenceToken, EditorCommand, EditorCommandCatalog, EditorCommandDescriptor,
|
||||
@@ -17,6 +18,10 @@ pub use model::{
|
||||
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
|
||||
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
|
||||
};
|
||||
pub use tiptap::{
|
||||
EditorBlockDocumentTiptapBridge, EditorBlockDocumentTiptapError, TiptapBlockType,
|
||||
TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapNode, TiptapTaskItemAttrs,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
use crate::editor::model::{
|
||||
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
|
||||
TextMark,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TiptapBlockType {
|
||||
Paragraph,
|
||||
Heading,
|
||||
BulletList,
|
||||
OrderedList,
|
||||
TaskList,
|
||||
ListItem,
|
||||
TaskItem,
|
||||
Blockquote,
|
||||
CodeBlock,
|
||||
Text,
|
||||
HardBreak,
|
||||
Doc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapHeadingAttrs {
|
||||
pub level: u8,
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapTaskItemAttrs {
|
||||
#[serde(default)]
|
||||
pub checked: bool,
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapParagraphAttrs {
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapListAttrs {
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapListItemAttrs {
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapBlockquoteAttrs {
|
||||
#[serde(default)]
|
||||
pub block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapCodeBlockAttrs {
|
||||
pub language: Option<String>,
|
||||
pub block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TiptapMark {
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum TiptapNode {
|
||||
Doc {
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
Paragraph {
|
||||
#[serde(default)]
|
||||
attrs: TiptapParagraphAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
Heading {
|
||||
attrs: TiptapHeadingAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
BulletList {
|
||||
#[serde(default)]
|
||||
attrs: TiptapListAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
OrderedList {
|
||||
#[serde(default)]
|
||||
attrs: TiptapListAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
TaskList {
|
||||
#[serde(default)]
|
||||
attrs: TiptapListAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
ListItem {
|
||||
#[serde(default)]
|
||||
attrs: TiptapListItemAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
TaskItem {
|
||||
attrs: TiptapTaskItemAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
Blockquote {
|
||||
#[serde(default)]
|
||||
attrs: TiptapBlockquoteAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
CodeBlock {
|
||||
attrs: TiptapCodeBlockAttrs,
|
||||
#[serde(default)]
|
||||
content: Vec<TiptapNode>,
|
||||
},
|
||||
Text {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
marks: Vec<TiptapMark>,
|
||||
},
|
||||
HardBreak,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditorBlockDocumentTiptapError {
|
||||
UnsupportedNode(&'static str),
|
||||
InvalidDocument(&'static str),
|
||||
}
|
||||
|
||||
pub struct EditorBlockDocumentTiptapBridge;
|
||||
|
||||
impl EditorBlockDocumentTiptapBridge {
|
||||
pub fn to_tiptap_doc(
|
||||
document: &EditorBlockDocument,
|
||||
) -> Result<TiptapNode, EditorBlockDocumentTiptapError> {
|
||||
let mut content = Vec::new();
|
||||
for block_id in &document.root_block_ids {
|
||||
let block = document
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|block| &block.block_id == block_id)
|
||||
.ok_or(EditorBlockDocumentTiptapError::InvalidDocument(
|
||||
"根块不存在",
|
||||
))?;
|
||||
content.push(block_to_tiptap_node(block)?);
|
||||
}
|
||||
Ok(TiptapNode::Doc { content })
|
||||
}
|
||||
|
||||
pub fn from_tiptap_doc(
|
||||
document_id: impl Into<String>,
|
||||
doc: &TiptapNode,
|
||||
) -> Result<EditorBlockDocument, EditorBlockDocumentTiptapError> {
|
||||
match doc {
|
||||
TiptapNode::Doc { content } => {
|
||||
let mut blocks = Vec::new();
|
||||
let mut root_block_ids = Vec::new();
|
||||
for (index, node) in content.iter().enumerate() {
|
||||
let block = node_to_block(node, index)?;
|
||||
root_block_ids.push(block.block_id.clone());
|
||||
blocks.push(block);
|
||||
}
|
||||
Ok(EditorBlockDocument {
|
||||
document_id: document_id.into(),
|
||||
root_block_ids,
|
||||
blocks,
|
||||
})
|
||||
}
|
||||
_ => Err(EditorBlockDocumentTiptapError::InvalidDocument(
|
||||
"顶层必须是 doc",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDocumentTiptapError> {
|
||||
let content = text_nodes_to_tiptap(&block.content_nodes)?;
|
||||
match block.block_type {
|
||||
EditorBlockType::Paragraph => Ok(TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content,
|
||||
}),
|
||||
EditorBlockType::Heading => Ok(TiptapNode::Heading {
|
||||
attrs: TiptapHeadingAttrs {
|
||||
level: block.props.heading_level.unwrap_or(1),
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content,
|
||||
}),
|
||||
EditorBlockType::BulletListItem => Ok(TiptapNode::BulletList {
|
||||
attrs: TiptapListAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content: vec![TiptapNode::ListItem {
|
||||
attrs: TiptapListItemAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content,
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
EditorBlockType::NumberedListItem => Ok(TiptapNode::OrderedList {
|
||||
attrs: TiptapListAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content: vec![TiptapNode::ListItem {
|
||||
attrs: TiptapListItemAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content,
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
EditorBlockType::Todo => Ok(TiptapNode::TaskList {
|
||||
attrs: TiptapListAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content: vec![TiptapNode::TaskItem {
|
||||
attrs: TiptapTaskItemAttrs {
|
||||
checked: block.props.checked.unwrap_or(false),
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content,
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
EditorBlockType::Quote => Ok(TiptapNode::Blockquote {
|
||||
attrs: TiptapBlockquoteAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content,
|
||||
}],
|
||||
}),
|
||||
EditorBlockType::CodeBlock => Ok(TiptapNode::CodeBlock {
|
||||
attrs: TiptapCodeBlockAttrs {
|
||||
language: block.props.language.clone(),
|
||||
block_id: Some(block.block_id.clone()),
|
||||
},
|
||||
content,
|
||||
}),
|
||||
_ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(
|
||||
"当前块型暂不支持导出到 Tiptap",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_to_block(
|
||||
node: &TiptapNode,
|
||||
index: usize,
|
||||
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
|
||||
let fallback_block_id = format!("block_{}", index + 1);
|
||||
match node {
|
||||
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Paragraph,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: text_nodes_from_tiptap(content)?,
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
TiptapNode::Heading { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Heading,
|
||||
props: BlockProps {
|
||||
heading_level: Some(attrs.level),
|
||||
..BlockProps::default()
|
||||
},
|
||||
content_nodes: text_nodes_from_tiptap(content)?,
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
TiptapNode::BulletList { attrs, content } => list_node_to_block(
|
||||
attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
EditorBlockType::BulletListItem,
|
||||
content,
|
||||
),
|
||||
TiptapNode::OrderedList { attrs, content } => list_node_to_block(
|
||||
attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
EditorBlockType::NumberedListItem,
|
||||
content,
|
||||
),
|
||||
TiptapNode::TaskList { attrs, content } => {
|
||||
list_task_node_to_block(attrs.block_id.clone().unwrap_or(fallback_block_id), content)
|
||||
}
|
||||
TiptapNode::Blockquote { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Quote,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: text_nodes_from_tiptap(extract_block_container_content(content)?)?,
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
TiptapNode::CodeBlock { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::CodeBlock,
|
||||
props: BlockProps {
|
||||
language: attrs.language.clone(),
|
||||
..BlockProps::default()
|
||||
},
|
||||
content_nodes: text_nodes_from_tiptap(content)?,
|
||||
child_block_ids: vec![],
|
||||
}),
|
||||
_ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(
|
||||
"当前节点暂不支持导入为 EditorBlockDocument",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_node_to_block(
|
||||
block_id: String,
|
||||
block_type: EditorBlockType,
|
||||
content: &[TiptapNode],
|
||||
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
|
||||
Ok(EditorBlock {
|
||||
block_id,
|
||||
block_type,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: text_nodes_from_tiptap(extract_list_item_inline_content(content)?)?,
|
||||
child_block_ids: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
fn list_task_node_to_block(
|
||||
block_id: String,
|
||||
content: &[TiptapNode],
|
||||
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
|
||||
let Some(first) = content.first() else {
|
||||
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
|
||||
"任务列表节点缺少 task_item",
|
||||
));
|
||||
};
|
||||
let (checked, item_content) = match first {
|
||||
TiptapNode::TaskItem { attrs, content } => (
|
||||
Some(attrs.checked),
|
||||
extract_block_container_content(content)?,
|
||||
),
|
||||
_ => {
|
||||
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
|
||||
"任务列表节点首子节点类型不正确",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(EditorBlock {
|
||||
block_id,
|
||||
block_type: EditorBlockType::Todo,
|
||||
props: BlockProps {
|
||||
checked,
|
||||
..BlockProps::default()
|
||||
},
|
||||
content_nodes: text_nodes_from_tiptap(item_content)?,
|
||||
child_block_ids: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_list_item_inline_content(
|
||||
content: &[TiptapNode],
|
||||
) -> Result<&[TiptapNode], EditorBlockDocumentTiptapError> {
|
||||
let Some(first) = content.first() else {
|
||||
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
|
||||
"列表节点缺少 list_item/task_item",
|
||||
));
|
||||
};
|
||||
match first {
|
||||
TiptapNode::ListItem { content, .. } | TiptapNode::TaskItem { content, .. } => {
|
||||
extract_block_container_content(content)
|
||||
}
|
||||
_ => Err(EditorBlockDocumentTiptapError::InvalidDocument(
|
||||
"列表节点首子节点类型不正确",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_block_container_content(
|
||||
content: &[TiptapNode],
|
||||
) -> Result<&[TiptapNode], EditorBlockDocumentTiptapError> {
|
||||
let Some(first) = content.first() else {
|
||||
return Ok(content);
|
||||
};
|
||||
match first {
|
||||
TiptapNode::Paragraph { content, .. } => Ok(content),
|
||||
_ => Ok(content),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_nodes_to_tiptap(
|
||||
nodes: &[ContentNode],
|
||||
) -> Result<Vec<TiptapNode>, EditorBlockDocumentTiptapError> {
|
||||
let mut out = Vec::new();
|
||||
for node in nodes {
|
||||
match &node.payload {
|
||||
ContentNodePayload::Text { text, marks } => out.push(TiptapNode::Text {
|
||||
text: text.clone(),
|
||||
marks: marks.iter().map(text_mark_to_tiptap).collect(),
|
||||
}),
|
||||
ContentNodePayload::HardBreak => out.push(TiptapNode::HardBreak),
|
||||
ContentNodePayload::ReferenceToken { .. } => {
|
||||
return Err(EditorBlockDocumentTiptapError::UnsupportedNode(
|
||||
"引用 token 暂不映射到 Tiptap 文本合同",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn text_nodes_from_tiptap(
|
||||
nodes: &[TiptapNode],
|
||||
) -> Result<Vec<ContentNode>, EditorBlockDocumentTiptapError> {
|
||||
let mut out = Vec::new();
|
||||
for node in nodes {
|
||||
match node {
|
||||
TiptapNode::Text { text, marks } => out.push(ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: text.clone(),
|
||||
marks: marks.iter().filter_map(tiptap_mark_to_text_mark).collect(),
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}),
|
||||
TiptapNode::HardBreak => out.push(ContentNode {
|
||||
payload: ContentNodePayload::HardBreak,
|
||||
attrs: BTreeMap::new(),
|
||||
}),
|
||||
_ => {
|
||||
return Err(EditorBlockDocumentTiptapError::UnsupportedNode(
|
||||
"当前只支持文本节点内容",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn text_mark_to_tiptap(mark: &TextMark) -> TiptapMark {
|
||||
let r#type = match mark {
|
||||
TextMark::Bold => "bold",
|
||||
TextMark::Italic => "italic",
|
||||
TextMark::Underline => "underline",
|
||||
TextMark::Strike => "strike",
|
||||
TextMark::Code => "code",
|
||||
};
|
||||
TiptapMark {
|
||||
r#type: r#type.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tiptap_mark_to_text_mark(mark: &TiptapMark) -> Option<TextMark> {
|
||||
match mark.r#type.as_str() {
|
||||
"bold" => Some(TextMark::Bold),
|
||||
"italic" => Some(TextMark::Italic),
|
||||
"underline" => Some(TextMark::Underline),
|
||||
"strike" => Some(TextMark::Strike),
|
||||
"code" => Some(TextMark::Code),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use core_protocol::editor::{
|
||||
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument,
|
||||
EditorBlockDocumentTiptapBridge, EditorBlockType, TextMark, TiptapCodeBlockAttrs,
|
||||
TiptapHeadingAttrs, TiptapNode, TiptapParagraphAttrs, TiptapTaskItemAttrs,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn p05_blocks_round_trip_through_tiptap_doc() {
|
||||
let document = EditorBlockDocument {
|
||||
document_id: "doc_1".into(),
|
||||
root_block_ids: vec![
|
||||
"p1".into(),
|
||||
"h1".into(),
|
||||
"b1".into(),
|
||||
"n1".into(),
|
||||
"t1".into(),
|
||||
"q1".into(),
|
||||
"c1".into(),
|
||||
],
|
||||
blocks: vec![
|
||||
EditorBlock {
|
||||
block_id: "p1".into(),
|
||||
block_type: EditorBlockType::Paragraph,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: "段落".into(),
|
||||
marks: vec![TextMark::Bold],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}],
|
||||
child_block_ids: vec![],
|
||||
},
|
||||
EditorBlock {
|
||||
block_id: "h1".into(),
|
||||
block_type: EditorBlockType::Heading,
|
||||
props: BlockProps {
|
||||
heading_level: Some(2),
|
||||
..BlockProps::default()
|
||||
},
|
||||
content_nodes: vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: "标题".into(),
|
||||
marks: vec![],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}],
|
||||
child_block_ids: vec![],
|
||||
},
|
||||
EditorBlock {
|
||||
block_id: "b1".into(),
|
||||
block_type: EditorBlockType::BulletListItem,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: "项目".into(),
|
||||
marks: vec![],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}],
|
||||
child_block_ids: vec![],
|
||||
},
|
||||
EditorBlock {
|
||||
block_id: "n1".into(),
|
||||
block_type: EditorBlockType::NumberedListItem,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: "序号".into(),
|
||||
marks: vec![],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}],
|
||||
child_block_ids: vec![],
|
||||
},
|
||||
EditorBlock {
|
||||
block_id: "t1".into(),
|
||||
block_type: EditorBlockType::Todo,
|
||||
props: BlockProps {
|
||||
checked: Some(true),
|
||||
..BlockProps::default()
|
||||
},
|
||||
content_nodes: vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: "待办".into(),
|
||||
marks: vec![],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}],
|
||||
child_block_ids: vec![],
|
||||
},
|
||||
EditorBlock {
|
||||
block_id: "q1".into(),
|
||||
block_type: EditorBlockType::Quote,
|
||||
props: BlockProps::default(),
|
||||
content_nodes: vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: "引用".into(),
|
||||
marks: vec![],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}],
|
||||
child_block_ids: vec![],
|
||||
},
|
||||
EditorBlock {
|
||||
block_id: "c1".into(),
|
||||
block_type: EditorBlockType::CodeBlock,
|
||||
props: BlockProps {
|
||||
language: Some("rust".into()),
|
||||
..BlockProps::default()
|
||||
},
|
||||
content_nodes: vec![ContentNode {
|
||||
payload: ContentNodePayload::Text {
|
||||
text: "fn main() {}".into(),
|
||||
marks: vec![TextMark::Code],
|
||||
},
|
||||
attrs: BTreeMap::new(),
|
||||
}],
|
||||
child_block_ids: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let doc =
|
||||
EditorBlockDocumentTiptapBridge::to_tiptap_doc(&document).expect("export should work");
|
||||
let round_trip = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_2", &doc)
|
||||
.expect("import should work");
|
||||
|
||||
assert_eq!(
|
||||
round_trip.root_block_ids,
|
||||
vec!["p1", "h1", "b1", "n1", "t1", "q1", "c1"]
|
||||
);
|
||||
assert_eq!(round_trip.blocks[0].block_id, "p1");
|
||||
assert_eq!(round_trip.blocks[0].content_nodes.len(), 1);
|
||||
match &round_trip.blocks[0].content_nodes[0].payload {
|
||||
ContentNodePayload::Text { marks, .. } => assert_eq!(marks, &vec![TextMark::Bold]),
|
||||
other => panic!("unexpected payload: {other:?}"),
|
||||
}
|
||||
assert_eq!(round_trip.blocks[1].props.heading_level, Some(2));
|
||||
assert_eq!(round_trip.blocks[4].props.checked, Some(true));
|
||||
assert_eq!(round_trip.blocks[6].props.language.as_deref(), Some("rust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiptap_import_preserves_explicit_block_ids_and_marks() {
|
||||
let doc = TiptapNode::Doc {
|
||||
content: vec![
|
||||
TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("p-1".into()),
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "Hello".into(),
|
||||
marks: vec![core_protocol::editor::TiptapMark {
|
||||
r#type: "italic".into(),
|
||||
}],
|
||||
}],
|
||||
},
|
||||
TiptapNode::Heading {
|
||||
attrs: TiptapHeadingAttrs {
|
||||
level: 3,
|
||||
block_id: Some("h-1".into()),
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "H".into(),
|
||||
marks: vec![],
|
||||
}],
|
||||
},
|
||||
TiptapNode::TaskList {
|
||||
attrs: core_protocol::editor::TiptapListAttrs {
|
||||
block_id: Some("t-1".into()),
|
||||
},
|
||||
content: vec![TiptapNode::TaskItem {
|
||||
attrs: TiptapTaskItemAttrs {
|
||||
checked: true,
|
||||
block_id: Some("t-1".into()),
|
||||
},
|
||||
content: vec![TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some("t-1".into()),
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "todo".into(),
|
||||
marks: vec![],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
TiptapNode::CodeBlock {
|
||||
attrs: TiptapCodeBlockAttrs {
|
||||
language: Some("ts".into()),
|
||||
block_id: Some("c-1".into()),
|
||||
},
|
||||
content: vec![TiptapNode::Text {
|
||||
text: "let x = 1;".into(),
|
||||
marks: vec![core_protocol::editor::TiptapMark {
|
||||
r#type: "code".into(),
|
||||
}],
|
||||
}],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let imported = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_3", &doc).unwrap();
|
||||
assert_eq!(imported.root_block_ids, vec!["p-1", "h-1", "t-1", "c-1"]);
|
||||
assert_eq!(imported.blocks[0].block_id, "p-1");
|
||||
assert_eq!(imported.blocks[1].props.heading_level, Some(3));
|
||||
assert_eq!(imported.blocks[2].props.checked, Some(true));
|
||||
assert_eq!(imported.blocks[3].props.language.as_deref(), Some("ts"));
|
||||
|
||||
match &imported.blocks[0].content_nodes[0].payload {
|
||||
ContentNodePayload::Text { marks, .. } => assert_eq!(marks, &vec![TextMark::Italic]),
|
||||
other => panic!("unexpected payload: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ pub struct DocumentSaveRequest {
|
||||
pub workspace_id: Option<String>,
|
||||
pub revision: Option<u64>,
|
||||
pub conflict_detection_key: Option<String>,
|
||||
pub editor_document: Option<Value>,
|
||||
pub content: Value,
|
||||
pub tiptap_document: Option<Value>,
|
||||
pub snapshot_captured_at: Option<String>,
|
||||
pub block_count: Option<u64>,
|
||||
}
|
||||
@@ -346,7 +348,9 @@ async fn proxy_next_documents_save(
|
||||
"workspaceId": effective_workspace_id,
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
"snapshotCapturedAt": body.snapshot_captured_at,
|
||||
"blockCount": body.block_count,
|
||||
})),
|
||||
@@ -472,7 +476,9 @@ pub async fn save(
|
||||
"workspaceId": effective_workspace_id,
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
"snapshotCapturedAt": body.snapshot_captured_at,
|
||||
"blockCount": body.block_count,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user