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