Fix tiptap selection sync and toolbar event loop

This commit is contained in:
lix-2026
2026-04-19 21:03:25 +08:00
parent 111a87d4fd
commit 394e2a155c
87 changed files with 17415 additions and 527 deletions
+373
View File
@@ -3,6 +3,7 @@ use bridge_runtime::{
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput,
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire,
};
use clap::ValueEnum;
use core_protocol::{
default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope,
CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind,
@@ -10,6 +11,11 @@ use core_protocol::{
QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments,
SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle,
};
use mnote_editor_core::{
apply_ai_pipeline, export_markdown, import_markdown, BlockType, DocumentBlock,
EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession,
VisibilitySnapshot,
};
use reqwest::blocking::Client;
use serde::Serialize;
use serde_json::{json, Value};
@@ -130,6 +136,80 @@ pub struct CliTransportPlan {
pub args_json: Value,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCliOutput {
pub ok: bool,
pub entrypoint: &'static str,
pub operation: &'static str,
pub block_count: usize,
pub output_markdown: String,
pub visible_block_ids: Vec<String>,
pub outline: Vec<EditorOutlineItem>,
pub audit: Vec<EditorAuditItem>,
pub change_report: EditorChangeReportOutput,
#[serde(skip_serializing_if = "Option::is_none")]
pub undo_applied: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redo_applied: Option<bool>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorOutlineItem {
pub block_id: String,
pub level: u8,
pub title: String,
pub depth: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorAuditItem {
pub command: String,
pub target_block_id: Option<String>,
pub before: Option<EditorBlockSnapshot>,
pub after: Option<EditorBlockSnapshot>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorBlockSnapshot {
pub block_id: String,
pub block_type: String,
pub text: String,
pub parent_id: Option<String>,
pub indent: u16,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct EditorChangeReportOutput {
#[serde(default)]
pub created_blocks: Vec<String>,
#[serde(default)]
pub updated_blocks: Vec<String>,
#[serde(default)]
pub moved_blocks: Vec<String>,
#[serde(default)]
pub removed_blocks: Vec<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum EditorInputKindArg {
PlainText,
Markdown,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum EditorAiScenarioArg {
MeetingNotesToTodos,
LongParagraphToTitle,
PageReorder,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageCreateArgs<'a> {
pub page_id: &'a str,
@@ -194,6 +274,8 @@ pub fn supported_command_surface() -> Vec<&'static str> {
"search documents",
"search blocks",
"sidebar dataset",
"editor markdown-roundtrip",
"editor session-demo",
"tool run",
];
for tool_name in default_tool_registry().tool_names() {
@@ -223,6 +305,8 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
"search.documents",
"search.blocks",
"sidebar.dataset",
"editor.markdown_roundtrip",
"editor.session_demo",
"tool.run",
];
for tool_name in default_tool_registry().tool_names() {
@@ -231,6 +315,179 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
contracts
}
pub fn editor_markdown_roundtrip(markdown: &str) -> CliResult<EditorCliOutput> {
let document = import_markdown(markdown).map_err(map_editor_error)?;
let projection = VisibilitySnapshot::derive(&document);
Ok(build_editor_output(
"markdown_roundtrip",
document.blocks().len(),
export_markdown(&document),
&projection,
Vec::new(),
EditorChangeReportOutput::default(),
None,
None,
))
}
pub fn editor_ai_pipeline(
kind: EditorInputKind,
scenario: EditorAiScenario,
input: &str,
) -> CliResult<EditorCliOutput> {
let result = apply_ai_pipeline(EditorPipelineRequest {
kind,
scenario,
input: input.to_string(),
})
.map_err(map_editor_error)?;
let projection = VisibilitySnapshot::derive(&result.document);
Ok(build_editor_output(
"ai_pipeline",
result.document.blocks().len(),
result.markdown,
&projection,
result
.audit
.into_iter()
.map(|item| EditorAuditItem {
command: item.command,
target_block_id: item.target_block_id,
before: item.before.map(|snapshot| EditorBlockSnapshot {
block_id: snapshot.block_id,
block_type: snapshot.block_type,
text: snapshot.text,
parent_id: snapshot.parent_id,
indent: snapshot.indent,
}),
after: item.after.map(|snapshot| EditorBlockSnapshot {
block_id: snapshot.block_id,
block_type: snapshot.block_type,
text: snapshot.text,
parent_id: snapshot.parent_id,
indent: snapshot.indent,
}),
})
.collect(),
EditorChangeReportOutput {
created_blocks: result.change_report.created_blocks,
updated_blocks: result.change_report.updated_blocks,
moved_blocks: result.change_report.moved_blocks,
removed_blocks: result.change_report.removed_blocks,
notes: result.change_report.notes,
},
None,
None,
))
}
pub fn editor_session_demo(markdown: &str) -> CliResult<EditorCliOutput> {
let document = import_markdown(markdown).map_err(map_editor_error)?;
let bootstrap = if document.blocks().is_empty() {
mnote_editor_core::DocumentModel::new(vec![DocumentBlock::new(
"demo_root",
BlockType::Paragraph,
)
.with_text("demo root")])
} else {
document
};
let mut session = EditorSession::new(bootstrap);
let primary_block_id = session
.document()
.blocks()
.first()
.map(|block| block.id.clone())
.ok_or_else(|| CliError::validation("session demo 缺少可编辑块"))?;
let original_text = session
.document()
.block(&primary_block_id)
.map(|block| block.content.text.clone())
.unwrap_or_default();
let next_text = if original_text.trim().is_empty() {
"demo edited".to_string()
} else {
format!("{original_text} [edited]")
};
session
.apply_command(EditorCommand::ReplaceBlock {
block_id: primary_block_id.clone(),
text: next_text,
})
.map_err(map_editor_error)?;
session
.apply_command(EditorCommand::InsertBlockAfter {
after_block_id: Some(primary_block_id),
block: DocumentBlock::new("cli_demo_block", BlockType::Paragraph)
.with_text("CLI demo block"),
})
.map_err(map_editor_error)?;
let undo_applied = session.undo();
let redo_applied = session.redo();
let projection = VisibilitySnapshot::derive(session.document());
Ok(build_editor_output(
"session_demo",
session.document().blocks().len(),
export_markdown(session.document()),
&projection,
Vec::new(),
EditorChangeReportOutput::default(),
Some(undo_applied),
Some(redo_applied),
))
}
pub fn render_editor_plain_output(output: &EditorCliOutput) -> String {
format!(
"entrypoint={} operation={} block_count={} visible_blocks={} audit_count={} updated_blocks={} undo_applied={} redo_applied={}",
output.entrypoint,
output.operation,
output.block_count,
output.visible_block_ids.join(","),
output.audit.len(),
output.change_report.updated_blocks.join(","),
output.undo_applied.unwrap_or(false),
output.redo_applied.unwrap_or(false),
)
}
fn build_editor_output(
operation: &'static str,
block_count: usize,
output_markdown: String,
projection: &VisibilitySnapshot,
audit: Vec<EditorAuditItem>,
change_report: EditorChangeReportOutput,
undo_applied: Option<bool>,
redo_applied: Option<bool>,
) -> EditorCliOutput {
EditorCliOutput {
ok: true,
entrypoint: "mnote-cli",
operation,
block_count,
output_markdown,
visible_block_ids: projection.visible_block_ids.clone(),
outline: projection
.outline
.iter()
.map(|entry| EditorOutlineItem {
block_id: entry.block_id.clone(),
level: entry.level,
title: entry.title.clone(),
depth: entry.depth,
})
.collect(),
audit,
change_report,
undo_applied,
redo_applied,
}
}
pub fn render_plain_output(output: &CliJsonOutput) -> String {
match &output.operation {
CliOperationOutput::Command {
@@ -2359,6 +2616,10 @@ fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError {
CliError::validation(error.message)
}
fn map_editor_error(error: mnote_editor_core::CoreError) -> CliError {
CliError::validation(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2374,9 +2635,121 @@ mod tests {
assert!(commands.contains(&"mindmap get"));
assert!(commands.contains(&"search documents"));
assert!(commands.contains(&"sidebar dataset"));
assert!(commands.contains(&"editor markdown-roundtrip"));
assert!(commands.contains(&"editor session-demo"));
assert!(commands.contains(&"tool run"));
}
#[test]
fn editor_markdown_roundtrip_returns_outline_and_markdown() {
let output =
editor_markdown_roundtrip("# 标题\n普通段落").expect("roundtrip should succeed");
assert_eq!(output.operation, "markdown_roundtrip");
assert_eq!(output.block_count, 2);
assert!(output.output_markdown.contains("# 标题"));
assert_eq!(
output.visible_block_ids,
vec!["imported_1".to_string(), "imported_2".to_string()]
);
assert_eq!(output.outline.len(), 1);
assert_eq!(output.outline[0].title, "标题");
}
#[test]
fn editor_session_demo_applies_insert_and_redo() {
let output = editor_session_demo("原始段落").expect("session demo should succeed");
assert_eq!(output.operation, "session_demo");
assert_eq!(output.block_count, 2);
assert_eq!(output.undo_applied, Some(true));
assert_eq!(output.redo_applied, Some(true));
assert!(output.output_markdown.contains("原始段落 [edited]"));
assert!(output.output_markdown.contains("CLI demo block"));
}
#[test]
fn editor_ai_pipeline_exposes_audit_and_change_report() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::MeetingNotesToTodos,
"待办:整理会议纪要\n普通段落",
)
.expect("ai pipeline should succeed");
assert_eq!(output.operation, "ai_pipeline");
assert_eq!(output.audit.len(), 1);
assert_eq!(
output.change_report.updated_blocks,
vec!["imported_1".to_string()]
);
assert!(output.output_markdown.contains("待办:整理会议纪要"));
}
#[test]
fn editor_ai_pipeline_reorders_pages() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::PageReorder,
"第一页\n\n第二页",
)
.expect("ai pipeline should succeed");
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
assert_eq!(
output.change_report.moved_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn ai_regression_meeting_notes_to_todos() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::MeetingNotesToTodos,
"待办:整理会议纪要\n普通段落",
)
.expect("ai regression meeting notes should succeed");
assert!(output.output_markdown.contains("- [ ] 待办:整理会议纪要"));
assert_eq!(
output.change_report.updated_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn ai_regression_long_paragraph_to_title() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::LongParagraphToTitle,
"这是一个很长的段落,用来验证 AI 回归样例会把首块提炼成标题,并保留后续结构化内容。",
)
.expect("ai regression long paragraph should succeed");
assert!(output.output_markdown.starts_with("# 这是一个很长的段落"));
assert_eq!(
output.change_report.updated_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn ai_regression_page_reorder() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::PageReorder,
"第一页\n\n第二页",
)
.expect("ai regression reorder should succeed");
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
assert_eq!(
output.change_report.moved_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn page_save_json_contract_uses_documents_save() {
let output = plan_page_save(