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
@@ -0,0 +1,177 @@
use mnote_editor_core::{
BlockType, CommandExecutor, DocumentBlock, DocumentModel, EditorCommand, VisibilitySnapshot,
};
#[test]
fn command_executor_replaces_inserts_and_deletes_blocks() {
let mut document = DocumentModel::new(vec![
DocumentBlock::new("block_a", BlockType::Paragraph).with_text("hello"),
DocumentBlock::new("block_b", BlockType::Paragraph).with_text("world"),
]);
CommandExecutor::apply(
&mut document,
EditorCommand::ReplaceBlock {
block_id: "block_a".into(),
text: "hello mnote".into(),
},
)
.expect("replace should succeed");
CommandExecutor::apply(
&mut document,
EditorCommand::InsertBlockAfter {
after_block_id: Some("block_a".into()),
block: DocumentBlock::new("block_h", BlockType::Heading)
.with_heading_level(2)
.with_text("section"),
},
)
.expect("insert should succeed");
CommandExecutor::apply(
&mut document,
EditorCommand::DeleteBlock {
block_id: "block_b".into(),
},
)
.expect("delete should succeed");
assert_eq!(
document
.blocks()
.iter()
.map(|block| block.id.as_str())
.collect::<Vec<_>>(),
vec!["block_a", "block_h"]
);
assert_eq!(
document.block("block_a").expect("block_a").content.text,
"hello mnote"
);
}
#[test]
fn command_executor_splits_merges_moves_and_reindents_blocks() {
let mut document = DocumentModel::new(vec![
DocumentBlock::new("block_h", BlockType::Heading)
.with_heading_level(1)
.with_text("root"),
DocumentBlock::new("block_b", BlockType::Paragraph).with_text("AlphaBeta"),
DocumentBlock::new("block_c", BlockType::Paragraph).with_text("Tail"),
]);
CommandExecutor::apply(
&mut document,
EditorCommand::SplitBlock {
block_id: "block_b".into(),
offset: 5,
new_block_id: "block_d".into(),
},
)
.expect("split should succeed");
assert_eq!(
document.block("block_b").expect("block_b").content.text,
"Alpha"
);
assert_eq!(
document.block("block_d").expect("block_d").content.text,
"Beta"
);
CommandExecutor::apply(
&mut document,
EditorCommand::MergeWithPrevious {
block_id: "block_d".into(),
},
)
.expect("merge should succeed");
assert_eq!(
document.block("block_b").expect("block_b").content.text,
"AlphaBeta"
);
CommandExecutor::apply(
&mut document,
EditorCommand::MoveBlock {
block_id: "block_c".into(),
after_block_id: Some("block_h".into()),
},
)
.expect("move should succeed");
assert_eq!(
document
.blocks()
.iter()
.map(|block| block.id.as_str())
.collect::<Vec<_>>(),
vec!["block_h", "block_c", "block_b"]
);
CommandExecutor::apply(
&mut document,
EditorCommand::IndentBlock {
block_id: "block_b".into(),
},
)
.expect("indent should succeed");
let indented = document.block("block_b").expect("block_b after indent");
assert_eq!(indented.parent_id.as_deref(), Some("block_c"));
assert_eq!(indented.indent, 1);
CommandExecutor::apply(
&mut document,
EditorCommand::OutdentBlock {
block_id: "block_b".into(),
},
)
.expect("outdent should succeed");
let outdented = document.block("block_b").expect("block_b after outdent");
assert_eq!(outdented.parent_id, None);
assert_eq!(outdented.indent, 0);
}
#[test]
fn command_executor_toggles_heading_collapse_visibility() {
let mut document = DocumentModel::new(vec![
DocumentBlock::new("heading_root", BlockType::Heading)
.with_heading_level(1)
.with_text("root"),
DocumentBlock::new("child_para", BlockType::Paragraph)
.with_parent("heading_root", 1)
.with_text("hidden after collapse"),
DocumentBlock::new("tail", BlockType::Paragraph).with_text("tail"),
]);
CommandExecutor::apply(
&mut document,
EditorCommand::ToggleHeadingCollapse {
block_id: "heading_root".into(),
},
)
.expect("toggle collapse should succeed");
assert!(document.block("heading_root").expect("heading").collapsed);
let projection = VisibilitySnapshot::derive(&document);
assert_eq!(
projection.visible_block_ids,
vec!["heading_root".to_string(), "tail".to_string()]
);
}
#[test]
fn command_executor_changes_block_type() {
let mut document =
DocumentModel::new(vec![DocumentBlock::new("block_a", BlockType::Paragraph)]);
CommandExecutor::apply(
&mut document,
EditorCommand::SetBlockType {
block_id: "block_a".into(),
block_type: BlockType::Todo,
},
)
.expect("set block type should succeed");
assert_eq!(
document.block("block_a").expect("block_a").block_type,
BlockType::Todo
);
}
@@ -0,0 +1,46 @@
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, VisibilitySnapshot};
#[test]
fn document_model_preserves_parent_structure_and_outline_visibility() {
let document = DocumentModel::new(vec![
DocumentBlock::new("heading_root", BlockType::Heading)
.with_heading_level(1)
.with_text("根标题")
.with_collapsed(true),
DocumentBlock::new("paragraph_hidden", BlockType::Paragraph)
.with_parent("heading_root", 1)
.with_text("折叠后应隐藏"),
DocumentBlock::new("heading_visible", BlockType::Heading)
.with_heading_level(2)
.with_text("次级标题"),
DocumentBlock::new("todo_visible", BlockType::Todo)
.with_parent("heading_visible", 1)
.with_text("仍然可见"),
]);
assert_eq!(document.blocks().len(), 4);
assert_eq!(
document
.children_of(Some("heading_root"))
.into_iter()
.map(|block| block.id.as_str())
.collect::<Vec<_>>(),
vec!["paragraph_hidden"]
);
let projection = VisibilitySnapshot::derive(&document);
assert_eq!(
projection.visible_block_ids,
vec![
"heading_root".to_string(),
"heading_visible".to_string(),
"todo_visible".to_string(),
]
);
assert_eq!(projection.outline.len(), 2);
assert_eq!(projection.outline[0].block_id, "heading_root");
assert_eq!(projection.outline[0].title, "根标题");
assert_eq!(projection.outline[1].block_id, "heading_visible");
assert_eq!(projection.outline[1].level, 2);
}
@@ -0,0 +1,75 @@
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
#[test]
fn history_undo_redo_restores_command_sequence() {
let mut session = EditorSession::new(DocumentModel::new(vec![DocumentBlock::new(
"block_a",
BlockType::Paragraph,
)
.with_text("hello")]));
session
.apply_command(EditorCommand::ReplaceBlock {
block_id: "block_a".into(),
text: "hello world".into(),
})
.expect("replace should succeed");
session
.apply_command(EditorCommand::InsertBlockAfter {
after_block_id: Some("block_a".into()),
block: DocumentBlock::new("block_b", BlockType::Paragraph).with_text("tail"),
})
.expect("insert should succeed");
assert_eq!(session.document().blocks().len(), 2);
assert!(session.undo());
assert_eq!(session.document().blocks().len(), 1);
assert_eq!(
session
.document()
.block("block_a")
.expect("block_a after undo")
.content
.text,
"hello world"
);
assert!(session.undo());
assert_eq!(
session
.document()
.block("block_a")
.expect("block_a after second undo")
.content
.text,
"hello"
);
assert!(session.redo());
assert!(session.redo());
assert_eq!(session.document().blocks().len(), 2);
}
#[test]
fn history_clears_redo_after_new_command() {
let mut session = EditorSession::new(DocumentModel::new(vec![DocumentBlock::new(
"block_a",
BlockType::Paragraph,
)
.with_text("hello")]));
session
.apply_command(EditorCommand::ReplaceBlock {
block_id: "block_a".into(),
text: "hello world".into(),
})
.expect("replace should succeed");
assert!(session.undo());
session
.apply_command(EditorCommand::InsertBlockAfter {
after_block_id: Some("block_a".into()),
block: DocumentBlock::new("block_b", BlockType::Paragraph).with_text("fresh"),
})
.expect("insert should succeed");
assert!(!session.redo());
assert_eq!(session.document().blocks().len(), 2);
}
@@ -0,0 +1,95 @@
use mnote_editor_core::{
export_markdown, import_markdown, import_plain_text, BlockType, ReferenceKind,
};
#[test]
fn markdown_import_export_round_trip_preserves_block_kinds() {
let source = r#"# 根标题
- 列表项
- [x] 已完成
> 引用
---
```rust
fn main() {}
```
[[page_1|页面一]]
((block_1|块一))
普通段落
"#;
let document = import_markdown(source).expect("markdown import should succeed");
assert_eq!(
document
.blocks()
.iter()
.map(|block| block.block_type.clone())
.collect::<Vec<_>>(),
vec![
BlockType::Heading,
BlockType::BulletListItem,
BlockType::Todo,
BlockType::Quote,
BlockType::Divider,
BlockType::CodeBlock,
BlockType::PageReference,
BlockType::BlockReference,
BlockType::Paragraph,
]
);
let exported = export_markdown(&document);
let reparsed = import_markdown(&exported).expect("markdown re-import should succeed");
assert_eq!(
reparsed
.blocks()
.iter()
.map(|block| block.block_type.clone())
.collect::<Vec<_>>(),
document
.blocks()
.iter()
.map(|block| block.block_type.clone())
.collect::<Vec<_>>()
);
}
#[test]
fn markdown_export_renders_reference_tokens_and_code_fence() {
let source = r#"[[page_42|设计文档]]
((block_99|引用块))
```ts
console.log("ok");
```
"#;
let document = import_markdown(source).expect("markdown import should succeed");
let page_ref = document.blocks()[0]
.content
.reference
.as_ref()
.expect("page reference");
assert_eq!(page_ref.kind, ReferenceKind::Page);
let block_ref = document.blocks()[1]
.content
.reference
.as_ref()
.expect("block reference");
assert_eq!(block_ref.kind, ReferenceKind::Block);
let exported = export_markdown(&document);
assert!(exported.contains("[[page_42|设计文档]]"));
assert!(exported.contains("((block_99|引用块))"));
assert!(exported.contains("```ts"));
assert!(exported.contains("console.log(\"ok\");"));
}
#[test]
fn plain_text_import_splits_paragraphs_and_normalizes_line_breaks() {
let document =
import_plain_text("第一段\n继续\n\n第二段").expect("plain text import should succeed");
assert_eq!(document.blocks().len(), 2);
assert_eq!(document.blocks()[0].content.text, "第一段 继续");
assert_eq!(document.blocks()[1].content.text, "第二段");
assert_eq!(document.blocks()[0].id, "imported_1");
assert_eq!(document.blocks()[1].id, "imported_2");
}
@@ -0,0 +1,63 @@
use mnote_editor_core::{
apply_ai_pipeline, EditorAiScenario, EditorInputKind, EditorPipelineRequest,
};
#[test]
fn ai_pipeline_turns_meeting_notes_into_todos() {
let result = apply_ai_pipeline(EditorPipelineRequest {
kind: EditorInputKind::PlainText,
scenario: EditorAiScenario::MeetingNotesToTodos,
input: "待办:整理会议纪要\n普通段落".into(),
})
.expect("pipeline should succeed");
assert_eq!(
result.document.blocks()[0].block_type.as_editor_label(),
"todo"
);
assert_eq!(result.audit.len(), 1);
assert!(result
.change_report
.updated_blocks
.contains(&"imported_1".to_string()));
assert!(result.markdown.contains("待办:整理会议纪要"));
}
#[test]
fn ai_pipeline_refines_long_paragraph_to_title() {
let result = apply_ai_pipeline(EditorPipelineRequest {
kind: EditorInputKind::PlainText,
scenario: EditorAiScenario::LongParagraphToTitle,
input: "这是一个很长的段落 用于提炼 标题".into(),
})
.expect("pipeline should succeed");
assert_eq!(
result.document.blocks()[0].block_type.as_editor_label(),
"heading"
);
assert_eq!(result.document.blocks()[0].heading_level, Some(1));
assert_eq!(result.audit.len(), 1);
assert!(result
.change_report
.updated_blocks
.contains(&"imported_1".to_string()));
}
#[test]
fn ai_pipeline_reorders_first_block_after_second() {
let result = apply_ai_pipeline(EditorPipelineRequest {
kind: EditorInputKind::PlainText,
scenario: EditorAiScenario::PageReorder,
input: "第一页\n\n第二页".into(),
})
.expect("pipeline should succeed");
assert_eq!(result.document.blocks()[0].content.text, "第二页");
assert_eq!(result.document.blocks()[1].content.text, "第一页");
assert_eq!(result.audit.len(), 1);
assert_eq!(
result.change_report.moved_blocks,
vec!["imported_1".to_string()]
);
}