主编辑区改造准备

This commit is contained in:
lix-2026
2026-04-21 06:26:35 +08:00
parent 1e686bfa3c
commit 5d1c94eb9e
49 changed files with 6730 additions and 2565 deletions
+532 -7
View File
@@ -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,
}),
+1
View File
@@ -971,6 +971,7 @@ name = "mnote-leptos-tiptap-spike"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"js-sys",
"leptos",
"leptos-tiptap",
"leptos_dom",
+2 -1
View File
@@ -11,10 +11,11 @@ leptos = { version = "0.8.19", features = ["csr"] }
leptos_dom = "0.8.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
js-sys = "0.3.77"
wasm-bindgen = "0.2"
web-sys = { version = "0.3.77", features = ["DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "MouseEvent", "Node", "Range", "Selection", "Storage", "Window"] }
[dependencies.leptos-tiptap]
path = "../../../design/blocknote/reference-code/leptos-tiptap"
path = "../../../design/05-editor-mainline/reference-code/leptos-tiptap"
default-features = false
features = ["component", "full"]
+118
View File
@@ -0,0 +1,118 @@
# 8123 leptos-tiptap runtime bridge
这是 `rust/spikes/leptos-tiptap-spike` 的宿主桥接说明,面向前端 host / QA。
## runtime 识别
宿主可用下面特征识别这套 runtime:
- `data-mnote-runtime="8123-leptos-tiptap-runtime"`
- `data-mnote-runtime-bridge="true"`
- `data-testid="mnote-leptos-tiptap-host"`
稳定锚点:
- root: `data-testid="mnote-leptos-tiptap-host"`
- stage: `#editor-stage` / `data-testid="mnote-leptos-tiptap-editor-stage"`
- editor root: `.editor-surface .ProseMirror` / `data-testid="mnote-leptos-tiptap-editor-root"`
- toolbar: `data-testid="mnote-leptos-tiptap-toolbar"`
- slash menu: `data-testid="mnote-leptos-tiptap-slash-menu"`
- handle: `data-testid="mnote-leptos-tiptap-handle"`
## 事件协议
所有事件都走同一 envelope
```json
{
"protocol": "mnote.leptos_tiptap.bridge.v1",
"runtime": "8123-leptos-tiptap-runtime",
"version": "1.0.0",
"source": "mnote:leptos-tiptap-spike",
"payload": {}
}
```
### `mnote:leptos-tiptap-spike:ready`
首次挂载完成时发送,告诉 host 这套 runtime 可以被嵌入。
payload 里至少包含:
- `runtime_name`
- `selectors`
- `supported_commands`
- `supports_embedded_mode`
### `mnote:leptos-tiptap-spike:state`
发送当前最小可观测状态。
payload 字段:
- `document_id`
- `title`
- `dirty_count`
- `selected_block_index`
- `editor_focused`
- `slash_open`
- `toolbar_open`
### `mnote:leptos-tiptap-spike:change`
内容变化时发送结构化文档快照。
payload 结构:
- `title`: 文档标题
- `content`: 当前文档 JSON
- `meta`: 附加运行态信息(如 dirty_count / selection / focus
### `mnote:leptos-tiptap-spike:save-request`
保存路径或 host 明确要求落盘时发送,payload 与 `change` 一致,但语义是“请宿主处理保存”。
### `mnote:leptos-tiptap-spike:command`
预留宿主到 runtime 的命令通道。当前 spike 只声明了最小命令集合,还没有把完整命令分发做成正式 host API。
## 选择器清单
建议 host/QA 只依赖这些稳定选择器:
- root: `[data-testid="mnote-leptos-tiptap-host"]`
- stage: `#editor-stage``[data-testid="mnote-leptos-tiptap-editor-stage"]`
- editor: `.editor-surface .ProseMirror``[data-testid="mnote-leptos-tiptap-editor-root"]`
- toolbar: `[data-testid="mnote-leptos-tiptap-toolbar"]`
- slash menu: `[data-testid="mnote-leptos-tiptap-slash-menu"]`
- handle: `[data-testid="mnote-leptos-tiptap-handle"]`
## 推荐嵌入方式
推荐把这套 runtime 作为 iframe 或独立容器挂载,然后由 host 监听 `document` 上的 `CustomEvent`
1. 先等待 `ready`
2.`ready.payload.selectors` 取稳定锚点。
3. 监听 `change` / `state` / `save-request`
4. 需要操作时再发 `command`
最小监听示例:
```ts
const onReady = (event: Event) => {
const detail = (event as CustomEvent).detail;
// detail.protocol / detail.payload.selectors
};
document.addEventListener('mnote:leptos-tiptap-spike:ready', onReady as EventListener);
document.addEventListener('mnote:leptos-tiptap-spike:change', onReady as EventListener);
document.addEventListener('mnote:leptos-tiptap-spike:state', onReady as EventListener);
document.addEventListener('mnote:leptos-tiptap-spike:save-request', onReady as EventListener);
```
## 已知限制
- 这仍是 8123 spike,不是正式主编辑器切流完成态。
- 目前桥接只保证稳定事件与稳定 selector,不保证 host 命令已经全量实现。
- 仍保留独立运行能力,host 不应依赖 debug 文案。
- 保存仍以 spike 本地草稿逻辑为主,真正主链保存合同还没迁移完。
+53 -20
View File
@@ -181,6 +181,36 @@ const SPIKE_STYLE: &str = r#"
padding: 28px 20px 72px;
}
.app-shell-embedded {
max-width: none;
margin: 0;
padding: 0;
}
.app-shell-embedded .hero-bar,
.app-shell-embedded .title-input,
.app-shell-embedded .editor-subcopy,
.app-shell-embedded .footer-strip,
.app-shell-embedded .debug-drawer,
.app-shell-embedded .editor-crumbs:first-child {
display: none;
}
.app-shell-embedded .editor-card {
border: none;
border-radius: 0;
box-shadow: none;
background: transparent;
}
.app-shell-embedded .editor-topbar {
padding: 8px 12px 0;
}
.app-shell-embedded .editor-stage {
padding-top: 0;
}
.hero-bar {
display: flex;
justify-content: space-between;
@@ -1209,10 +1239,7 @@ fn target_element(target: web_sys::EventTarget) -> Option<Element> {
})
}
fn event_target_matches_selector(
target: Option<web_sys::EventTarget>,
selector: &str,
) -> bool {
fn event_target_matches_selector(target: Option<web_sys::EventTarget>, selector: &str) -> bool {
target
.and_then(target_element)
.and_then(|element| element.closest(selector).ok().flatten())
@@ -2260,8 +2287,7 @@ fn App() -> impl IntoView {
let (text_selection_active, set_text_selection_active) = signal(false);
let (floating_toolbar_anchor, set_floating_toolbar_anchor) =
signal(None::<FloatingToolbarAnchor>);
let (locked_toolbar_anchor, set_locked_toolbar_anchor) =
signal(None::<FloatingToolbarAnchor>);
let (locked_toolbar_anchor, set_locked_toolbar_anchor) = signal(None::<FloatingToolbarAnchor>);
let (slash_open, set_slash_open) = signal(false);
let (slash_index, set_slash_index) = signal(0_usize);
let (turn_into_open, set_turn_into_open) = signal(false);
@@ -2596,6 +2622,10 @@ fn App() -> impl IntoView {
};
let selection_text = move || selection_summary(&selection_state.get());
let is_embedded = window()
.and_then(|win| win.location().search().ok())
.map(|search| search.contains("embedded=1"))
.unwrap_or(false);
let on_link_click = {
let editor = editor;
@@ -2633,7 +2663,7 @@ fn App() -> impl IntoView {
view! {
<style>{SPIKE_STYLE}</style>
<main class="app-shell">
<main class=move || if is_embedded { "app-shell app-shell-embedded" } else { "app-shell" }>
<section class="hero-bar">
<div class="hero-meta">
<span class="hero-tag">"P0 / Tiptap-like 主编辑器体验"</span>
@@ -2750,6 +2780,18 @@ fn App() -> impl IntoView {
set_hovered_block.set(None);
}
on:mouseover=move |event: MouseEvent| {
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
return;
}
let Some(target) = event.target() else {
return;
};
if let Some(block) = hovered_block_from_target(target) {
set_editor_focused.set(true);
set_hovered_block.set(Some(block));
}
}
on:mouseleave=move |_| {
if !block_menu_open.get_untracked() && dragging_block_index.get_untracked().is_none() {
set_hovered_block.set(None);
@@ -3717,19 +3759,10 @@ fn App() -> impl IntoView {
}
fn initial_content() -> TiptapContent {
TiptapContent::html(
r#"
<h1>准备接管主编辑器体验</h1>
<p>这页现在先只在 <strong>8123</strong> 做 P0 体验收口:你可以输入 <code>/</code> 打开命令菜单,也可以直接试试选中文字后的浮动工具条。</p>
<p>目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。</p>
<ul>
<li>标题、段落、引用、代码块</li>
<li>无序列表 / 有序列表 / Todo 列表</li>
<li>分割线、行内样式、链接和高亮</li>
</ul>
<p>请把光标放到这里,然后按 <code>/</code>。</p>
"#,
)
TiptapContent::json(serde_json::json!({
"type": "doc",
"content": []
}))
}
fn main() {