Fix tiptap selection sync and toolbar event loop
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user