Fix tiptap selection sync and toolbar event loop
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "mnote-editor-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -0,0 +1,292 @@
|
||||
use crate::error::{CoreError, CoreResult};
|
||||
use crate::model::{BlockType, DocumentBlock, DocumentModel};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditorCommand {
|
||||
SetBlockType {
|
||||
block_id: String,
|
||||
block_type: BlockType,
|
||||
},
|
||||
ReplaceBlock {
|
||||
block_id: String,
|
||||
text: String,
|
||||
},
|
||||
InsertBlockAfter {
|
||||
after_block_id: Option<String>,
|
||||
block: DocumentBlock,
|
||||
},
|
||||
DeleteBlock {
|
||||
block_id: String,
|
||||
},
|
||||
SplitBlock {
|
||||
block_id: String,
|
||||
offset: usize,
|
||||
new_block_id: String,
|
||||
},
|
||||
MergeWithPrevious {
|
||||
block_id: String,
|
||||
},
|
||||
MoveBlock {
|
||||
block_id: String,
|
||||
after_block_id: Option<String>,
|
||||
},
|
||||
IndentBlock {
|
||||
block_id: String,
|
||||
},
|
||||
OutdentBlock {
|
||||
block_id: String,
|
||||
},
|
||||
ToggleHeadingCollapse {
|
||||
block_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct CommandExecutor;
|
||||
|
||||
impl CommandExecutor {
|
||||
pub fn apply(document: &mut DocumentModel, command: EditorCommand) -> CoreResult<()> {
|
||||
match command {
|
||||
EditorCommand::SetBlockType {
|
||||
block_id,
|
||||
block_type,
|
||||
} => set_block_type(document, &block_id, block_type),
|
||||
EditorCommand::ReplaceBlock { block_id, text } => {
|
||||
replace_block(document, &block_id, text)
|
||||
}
|
||||
EditorCommand::InsertBlockAfter {
|
||||
after_block_id,
|
||||
block,
|
||||
} => insert_block_after(document, after_block_id.as_deref(), block),
|
||||
EditorCommand::DeleteBlock { block_id } => delete_block(document, &block_id),
|
||||
EditorCommand::SplitBlock {
|
||||
block_id,
|
||||
offset,
|
||||
new_block_id,
|
||||
} => split_block(document, &block_id, offset, new_block_id),
|
||||
EditorCommand::MergeWithPrevious { block_id } => {
|
||||
merge_with_previous(document, &block_id)
|
||||
}
|
||||
EditorCommand::MoveBlock {
|
||||
block_id,
|
||||
after_block_id,
|
||||
} => move_block(document, &block_id, after_block_id.as_deref()),
|
||||
EditorCommand::IndentBlock { block_id } => indent_block(document, &block_id),
|
||||
EditorCommand::OutdentBlock { block_id } => outdent_block(document, &block_id),
|
||||
EditorCommand::ToggleHeadingCollapse { block_id } => {
|
||||
toggle_heading_collapse(document, &block_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_block_type(
|
||||
document: &mut DocumentModel,
|
||||
block_id: &str,
|
||||
block_type: BlockType,
|
||||
) -> CoreResult<()> {
|
||||
let block = document
|
||||
.block_mut(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
block.block_type = block_type;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_block(document: &mut DocumentModel, block_id: &str, text: String) -> CoreResult<()> {
|
||||
let block = document
|
||||
.block_mut(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
block.content.text = text;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_block_after(
|
||||
document: &mut DocumentModel,
|
||||
after_block_id: Option<&str>,
|
||||
block: DocumentBlock,
|
||||
) -> CoreResult<()> {
|
||||
if document.contains_id(&block.id) {
|
||||
return Err(CoreError::DuplicateBlockId(block.id));
|
||||
}
|
||||
|
||||
let insert_index = match after_block_id {
|
||||
Some(target_id) => {
|
||||
let range = document
|
||||
.subtree_range(target_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(target_id.to_string()))?;
|
||||
*range.end() + 1
|
||||
}
|
||||
None => document.blocks().len(),
|
||||
};
|
||||
document.blocks_mut().insert(insert_index, block);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_block(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
document.blocks_mut().drain(range);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_block(
|
||||
document: &mut DocumentModel,
|
||||
block_id: &str,
|
||||
offset: usize,
|
||||
new_block_id: String,
|
||||
) -> CoreResult<()> {
|
||||
if document.contains_id(&new_block_id) {
|
||||
return Err(CoreError::DuplicateBlockId(new_block_id));
|
||||
}
|
||||
let index = document
|
||||
.index_of(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
let block = document.blocks()[index].clone();
|
||||
let split_at = byte_index_for_char(&block.content.text, offset).ok_or_else(|| {
|
||||
CoreError::InvalidSplitOffset {
|
||||
block_id: block_id.to_string(),
|
||||
offset,
|
||||
}
|
||||
})?;
|
||||
let left = block.content.text[..split_at].to_string();
|
||||
let right = block.content.text[split_at..].to_string();
|
||||
|
||||
{
|
||||
let current = document
|
||||
.block_mut(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
current.content.text = left;
|
||||
}
|
||||
|
||||
let mut next_block = block;
|
||||
next_block.id = new_block_id;
|
||||
next_block.content.text = right;
|
||||
next_block.collapsed = false;
|
||||
document.blocks_mut().insert(*range.end() + 1, next_block);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_with_previous(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let index = document
|
||||
.index_of(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
if index == 0 {
|
||||
return Err(CoreError::InvalidOperation("首块无法与上一块合并"));
|
||||
}
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
if *range.start() != *range.end() {
|
||||
return Err(CoreError::InvalidOperation(
|
||||
"当前最小实现不支持带子树的 merge",
|
||||
));
|
||||
}
|
||||
|
||||
let previous_index = index - 1;
|
||||
let text_to_append = document.blocks()[index].content.text.clone();
|
||||
document.blocks_mut()[previous_index]
|
||||
.content
|
||||
.text
|
||||
.push_str(&text_to_append);
|
||||
document.blocks_mut().remove(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_block(
|
||||
document: &mut DocumentModel,
|
||||
block_id: &str,
|
||||
after_block_id: Option<&str>,
|
||||
) -> CoreResult<()> {
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
let start = *range.start();
|
||||
let end = *range.end();
|
||||
let moved_ids: Vec<String> = document.blocks()[start..=end]
|
||||
.iter()
|
||||
.map(|block| block.id.clone())
|
||||
.collect();
|
||||
if let Some(target_id) = after_block_id {
|
||||
if moved_ids.iter().any(|id| id == target_id) {
|
||||
return Err(CoreError::InvalidOperation("不能把块移动到自己的子树后面"));
|
||||
}
|
||||
}
|
||||
|
||||
let moved_blocks: Vec<DocumentBlock> = document.blocks_mut().drain(start..=end).collect();
|
||||
let insert_index = match after_block_id {
|
||||
Some(target_id) => {
|
||||
let target_range = document
|
||||
.subtree_range(target_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(target_id.to_string()))?;
|
||||
*target_range.end() + 1
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
document
|
||||
.blocks_mut()
|
||||
.splice(insert_index..insert_index, moved_blocks);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn indent_block(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let index = document
|
||||
.index_of(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
if index == 0 {
|
||||
return Err(CoreError::InvalidOperation("首块无法缩进"));
|
||||
}
|
||||
let previous_id = document.blocks()[index - 1].id.clone();
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
for position in *range.start()..=*range.end() {
|
||||
document.blocks_mut()[position].indent += 1;
|
||||
}
|
||||
document.blocks_mut()[*range.start()].parent_id = Some(previous_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn outdent_block(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let block = document
|
||||
.block(block_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
let parent_id = block
|
||||
.parent_id
|
||||
.clone()
|
||||
.ok_or(CoreError::InvalidOperation("当前块已在根层级"))?;
|
||||
let next_parent_id = document
|
||||
.block(&parent_id)
|
||||
.and_then(|parent| parent.parent_id.clone());
|
||||
let range = document
|
||||
.subtree_range(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
for position in *range.start()..=*range.end() {
|
||||
let current_indent = document.blocks()[position].indent;
|
||||
if current_indent == 0 {
|
||||
return Err(CoreError::InvalidOperation("根层级块不能继续反缩进"));
|
||||
}
|
||||
document.blocks_mut()[position].indent = current_indent - 1;
|
||||
}
|
||||
document.blocks_mut()[*range.start()].parent_id = next_parent_id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toggle_heading_collapse(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
|
||||
let block = document
|
||||
.block_mut(block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
|
||||
block.collapsed = !block.collapsed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn byte_index_for_char(text: &str, offset: usize) -> Option<usize> {
|
||||
if offset == text.chars().count() {
|
||||
return Some(text.len());
|
||||
}
|
||||
text.char_indices().nth(offset).map(|(index, _)| index)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CoreError {
|
||||
BlockNotFound(String),
|
||||
DuplicateBlockId(String),
|
||||
InvalidOperation(&'static str),
|
||||
InvalidSplitOffset { block_id: String, offset: usize },
|
||||
MarkdownParse(String),
|
||||
}
|
||||
|
||||
impl Display for CoreError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BlockNotFound(block_id) => write!(f, "未找到块: {block_id}"),
|
||||
Self::DuplicateBlockId(block_id) => write!(f, "块 id 已存在: {block_id}"),
|
||||
Self::InvalidOperation(message) => write!(f, "无效操作: {message}"),
|
||||
Self::InvalidSplitOffset { block_id, offset } => {
|
||||
write!(f, "块 {block_id} 的拆分位置无效: {offset}")
|
||||
}
|
||||
Self::MarkdownParse(message) => write!(f, "Markdown 解析失败: {message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CoreError {}
|
||||
|
||||
pub type CoreResult<T> = Result<T, CoreError>;
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::command::{CommandExecutor, EditorCommand};
|
||||
use crate::error::CoreResult;
|
||||
use crate::model::DocumentModel;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorSession {
|
||||
document: DocumentModel,
|
||||
undo_stack: Vec<DocumentModel>,
|
||||
redo_stack: Vec<DocumentModel>,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
pub fn new(document: DocumentModel) -> Self {
|
||||
Self {
|
||||
document,
|
||||
undo_stack: Vec::new(),
|
||||
redo_stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn document(&self) -> &DocumentModel {
|
||||
&self.document
|
||||
}
|
||||
|
||||
pub fn apply_command(&mut self, command: EditorCommand) -> CoreResult<()> {
|
||||
let snapshot = self.document.clone();
|
||||
CommandExecutor::apply(&mut self.document, command)?;
|
||||
self.undo_stack.push(snapshot);
|
||||
self.redo_stack.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some(previous) = self.undo_stack.pop() else {
|
||||
return false;
|
||||
};
|
||||
self.redo_stack.push(self.document.clone());
|
||||
self.document = previous;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let Some(next) = self.redo_stack.pop() else {
|
||||
return false;
|
||||
};
|
||||
self.undo_stack.push(self.document.clone());
|
||||
self.document = next;
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
pub mod command;
|
||||
pub mod error;
|
||||
pub mod history;
|
||||
pub mod markdown;
|
||||
pub mod model;
|
||||
pub mod pipeline;
|
||||
pub mod projection;
|
||||
|
||||
pub use command::{CommandExecutor, EditorCommand};
|
||||
pub use error::{CoreError, CoreResult};
|
||||
pub use history::EditorSession;
|
||||
pub use markdown::{export_markdown, import_markdown, import_plain_text};
|
||||
pub use model::{
|
||||
BlockContent, BlockReference, BlockType, DocumentBlock, DocumentModel, ReferenceKind,
|
||||
};
|
||||
pub use pipeline::{
|
||||
apply_ai_pipeline, BlockSnapshot, CommandAuditRecord, EditorAiScenario, EditorChangeReport,
|
||||
EditorInputKind, EditorPipelineRequest, EditorPipelineResult,
|
||||
};
|
||||
pub use projection::{OutlineEntry, VisibilitySnapshot};
|
||||
@@ -0,0 +1,263 @@
|
||||
use crate::error::{CoreError, CoreResult};
|
||||
use crate::model::{BlockType, DocumentBlock, DocumentModel, ReferenceKind};
|
||||
|
||||
pub fn import_markdown(input: &str) -> CoreResult<DocumentModel> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut ancestry: Vec<String> = Vec::new();
|
||||
let mut next_id = 1usize;
|
||||
let mut lines = input.lines().peekable();
|
||||
|
||||
while let Some(line) = lines.next() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.trim_start().starts_with("```") {
|
||||
let language = line
|
||||
.trim_start()
|
||||
.trim_start_matches("```")
|
||||
.trim()
|
||||
.to_string();
|
||||
let mut code_lines = Vec::new();
|
||||
let mut closed = false;
|
||||
for next in lines.by_ref() {
|
||||
if next.trim_start().starts_with("```") {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
code_lines.push(next.to_string());
|
||||
}
|
||||
if !closed {
|
||||
return Err(CoreError::MarkdownParse("代码块缺少结束围栏".into()));
|
||||
}
|
||||
ancestry.clear();
|
||||
let mut block = DocumentBlock::new(next_block_id(&mut next_id), BlockType::CodeBlock)
|
||||
.with_text(code_lines.join("\n"));
|
||||
if !language.is_empty() {
|
||||
block = block.with_language(language);
|
||||
}
|
||||
blocks.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
let indent = count_indent(line);
|
||||
while ancestry.len() > indent as usize {
|
||||
ancestry.pop();
|
||||
}
|
||||
|
||||
let trimmed = line.trim_start();
|
||||
let mut block = if let Some((level, title)) = parse_heading(trimmed) {
|
||||
ancestry.clear();
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Heading)
|
||||
.with_heading_level(level)
|
||||
.with_text(title)
|
||||
} else if let Some((checked, text)) = parse_todo(trimmed) {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Todo)
|
||||
.with_checked(checked)
|
||||
.with_text(text)
|
||||
} else if let Some(text) = parse_bullet(trimmed) {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::BulletListItem)
|
||||
.with_text(text)
|
||||
} else if let Some(text) = parse_numbered(trimmed) {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::NumberedListItem)
|
||||
.with_text(text)
|
||||
} else if let Some(text) = trimmed.strip_prefix("> ") {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Quote).with_text(text)
|
||||
} else if trimmed == "---" {
|
||||
ancestry.clear();
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Divider)
|
||||
} else if let Some((target_id, label)) = parse_reference_token(trimmed, "[[", "]]") {
|
||||
ancestry.clear();
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::PageReference)
|
||||
.with_reference(ReferenceKind::Page, target_id, label)
|
||||
} else if let Some((target_id, label)) = parse_reference_token(trimmed, "((", "))") {
|
||||
ancestry.clear();
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::BlockReference)
|
||||
.with_reference(ReferenceKind::Block, target_id, label)
|
||||
} else {
|
||||
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Paragraph).with_text(trimmed)
|
||||
};
|
||||
|
||||
if indent > 0 {
|
||||
let parent_id = ancestry
|
||||
.last()
|
||||
.cloned()
|
||||
.ok_or(CoreError::MarkdownParse("缩进层级缺少父块".into()))?;
|
||||
block = block.with_parent(parent_id, indent);
|
||||
}
|
||||
ancestry.push(block.id.clone());
|
||||
blocks.push(block);
|
||||
}
|
||||
|
||||
Ok(DocumentModel::new(blocks))
|
||||
}
|
||||
|
||||
pub fn import_plain_text(input: &str) -> CoreResult<DocumentModel> {
|
||||
let mut blocks = Vec::new();
|
||||
let normalized = input.replace("\r\n", "\n");
|
||||
let mut next_id = 1usize;
|
||||
for paragraph in normalized
|
||||
.split("\n\n")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let block = DocumentBlock::new(next_block_id(&mut next_id), BlockType::Paragraph)
|
||||
.with_text(paragraph.replace('\n', " "));
|
||||
blocks.push(block);
|
||||
}
|
||||
Ok(DocumentModel::new(blocks))
|
||||
}
|
||||
|
||||
pub fn export_markdown(document: &DocumentModel) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for block in document.blocks() {
|
||||
let indent = " ".repeat(block.indent as usize);
|
||||
match block.block_type {
|
||||
BlockType::Heading => {
|
||||
let level = block.heading_level.unwrap_or(1).clamp(1, 6);
|
||||
lines.push(format!(
|
||||
"{indent}{} {}",
|
||||
"#".repeat(level as usize),
|
||||
block.content.text
|
||||
));
|
||||
}
|
||||
BlockType::BulletListItem => {
|
||||
lines.push(format!("{indent}- {}", block.content.text));
|
||||
}
|
||||
BlockType::NumberedListItem => {
|
||||
lines.push(format!("{indent}1. {}", block.content.text));
|
||||
}
|
||||
BlockType::Todo => {
|
||||
let marker = if block.checked.unwrap_or(false) {
|
||||
"x"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
lines.push(format!("{indent}- [{marker}] {}", block.content.text));
|
||||
}
|
||||
BlockType::Quote => {
|
||||
lines.push(format!("{indent}> {}", block.content.text));
|
||||
}
|
||||
BlockType::Divider => {
|
||||
lines.push(format!("{indent}---"));
|
||||
}
|
||||
BlockType::CodeBlock => {
|
||||
let fence = match block.content.language.as_deref() {
|
||||
Some(language) if !language.is_empty() => format!("{indent}```{language}"),
|
||||
_ => format!("{indent}```"),
|
||||
};
|
||||
lines.push(fence);
|
||||
for code_line in block.content.text.lines() {
|
||||
lines.push(format!("{indent}{code_line}"));
|
||||
}
|
||||
lines.push(format!("{indent}```"));
|
||||
}
|
||||
BlockType::PageReference => {
|
||||
lines.push(format!(
|
||||
"{indent}{}",
|
||||
render_reference_token(block.content.reference.as_ref(), "[[", "]]")
|
||||
));
|
||||
}
|
||||
BlockType::BlockReference => {
|
||||
lines.push(format!(
|
||||
"{indent}{}",
|
||||
render_reference_token(block.content.reference.as_ref(), "((", "))")
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
lines.push(format!("{indent}{}", block.content.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn next_block_id(next_id: &mut usize) -> String {
|
||||
let current = *next_id;
|
||||
*next_id += 1;
|
||||
format!("imported_{current}")
|
||||
}
|
||||
|
||||
fn count_indent(line: &str) -> u16 {
|
||||
let mut spaces = 0usize;
|
||||
for ch in line.chars() {
|
||||
match ch {
|
||||
' ' => spaces += 1,
|
||||
'\t' => spaces += 2,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
(spaces / 2) as u16
|
||||
}
|
||||
|
||||
fn parse_heading(line: &str) -> Option<(u8, &str)> {
|
||||
let level = line.chars().take_while(|ch| *ch == '#').count();
|
||||
if level == 0 || level > 6 {
|
||||
return None;
|
||||
}
|
||||
let title = line.get(level + 1..)?;
|
||||
if !line.as_bytes().get(level).is_some_and(|ch| *ch == b' ') {
|
||||
return None;
|
||||
}
|
||||
Some((level as u8, title))
|
||||
}
|
||||
|
||||
fn parse_todo(line: &str) -> Option<(bool, &str)> {
|
||||
if let Some(rest) = line.strip_prefix("- [ ] ") {
|
||||
return Some((false, rest));
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("- [x] ") {
|
||||
return Some((true, rest));
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("- [X] ") {
|
||||
return Some((true, rest));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_bullet(line: &str) -> Option<&str> {
|
||||
line.strip_prefix("- ").or_else(|| line.strip_prefix("* "))
|
||||
}
|
||||
|
||||
fn parse_numbered(line: &str) -> Option<&str> {
|
||||
let dot_index = line.find(". ")?;
|
||||
if line[..dot_index].chars().all(|ch| ch.is_ascii_digit()) {
|
||||
line.get(dot_index + 2..)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_reference_token(
|
||||
line: &str,
|
||||
prefix: &str,
|
||||
suffix: &str,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
let body = line.strip_prefix(prefix)?.strip_suffix(suffix)?;
|
||||
let mut parts = body.splitn(2, '|');
|
||||
let target_id = parts.next()?.trim();
|
||||
if target_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let label = parts.next().map(|value| value.trim().to_string());
|
||||
Some((
|
||||
target_id.to_string(),
|
||||
label.filter(|value| !value.is_empty()),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_reference_token(
|
||||
reference: Option<&crate::model::BlockReference>,
|
||||
prefix: &str,
|
||||
suffix: &str,
|
||||
) -> String {
|
||||
match reference {
|
||||
Some(reference) => match reference.label.as_deref() {
|
||||
Some(label) if !label.is_empty() => {
|
||||
format!("{prefix}{}|{label}{suffix}", reference.target_id)
|
||||
}
|
||||
_ => format!("{prefix}{}{suffix}", reference.target_id),
|
||||
},
|
||||
None => format!("{prefix}{suffix}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BlockType {
|
||||
Paragraph,
|
||||
Heading,
|
||||
BulletListItem,
|
||||
NumberedListItem,
|
||||
Todo,
|
||||
Quote,
|
||||
Divider,
|
||||
CodeBlock,
|
||||
PageReference,
|
||||
BlockReference,
|
||||
MediaPlaceholder,
|
||||
ProgressPlaceholder,
|
||||
}
|
||||
|
||||
impl BlockType {
|
||||
pub fn from_editor_label(label: &str) -> Option<Self> {
|
||||
match label {
|
||||
"paragraph" => Some(Self::Paragraph),
|
||||
"heading" => Some(Self::Heading),
|
||||
"bullet_list_item" => Some(Self::BulletListItem),
|
||||
"numbered_list_item" => Some(Self::NumberedListItem),
|
||||
"todo" => Some(Self::Todo),
|
||||
"quote" => Some(Self::Quote),
|
||||
"divider" => Some(Self::Divider),
|
||||
"code_block" => Some(Self::CodeBlock),
|
||||
"page_reference" => Some(Self::PageReference),
|
||||
"block_reference" => Some(Self::BlockReference),
|
||||
"media_placeholder" => Some(Self::MediaPlaceholder),
|
||||
"progress_placeholder" => Some(Self::ProgressPlaceholder),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_editor_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Paragraph => "paragraph",
|
||||
Self::Heading => "heading",
|
||||
Self::BulletListItem => "bullet_list_item",
|
||||
Self::NumberedListItem => "numbered_list_item",
|
||||
Self::Todo => "todo",
|
||||
Self::Quote => "quote",
|
||||
Self::Divider => "divider",
|
||||
Self::CodeBlock => "code_block",
|
||||
Self::PageReference => "page_reference",
|
||||
Self::BlockReference => "block_reference",
|
||||
Self::MediaPlaceholder => "media_placeholder",
|
||||
Self::ProgressPlaceholder => "progress_placeholder",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReferenceKind {
|
||||
Page,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BlockReference {
|
||||
pub kind: ReferenceKind,
|
||||
pub target_id: String,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct BlockContent {
|
||||
pub text: String,
|
||||
pub language: Option<String>,
|
||||
pub reference: Option<BlockReference>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DocumentBlock {
|
||||
pub id: String,
|
||||
pub block_type: BlockType,
|
||||
pub parent_id: Option<String>,
|
||||
pub indent: u16,
|
||||
pub collapsed: bool,
|
||||
pub heading_level: Option<u8>,
|
||||
pub checked: Option<bool>,
|
||||
pub content: BlockContent,
|
||||
}
|
||||
|
||||
impl DocumentBlock {
|
||||
pub fn new(id: impl Into<String>, block_type: BlockType) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
block_type,
|
||||
parent_id: None,
|
||||
indent: 0,
|
||||
collapsed: false,
|
||||
heading_level: None,
|
||||
checked: None,
|
||||
content: BlockContent::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_parent(mut self, parent_id: impl Into<String>, indent: u16) -> Self {
|
||||
self.parent_id = Some(parent_id.into());
|
||||
self.indent = indent;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_heading_level(mut self, level: u8) -> Self {
|
||||
self.heading_level = Some(level);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_text(mut self, text: impl Into<String>) -> Self {
|
||||
self.content.text = text.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_checked(mut self, checked: bool) -> Self {
|
||||
self.checked = Some(checked);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_language(mut self, language: impl Into<String>) -> Self {
|
||||
self.content.language = Some(language.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_reference(
|
||||
mut self,
|
||||
kind: ReferenceKind,
|
||||
target_id: impl Into<String>,
|
||||
label: Option<String>,
|
||||
) -> Self {
|
||||
self.content.reference = Some(BlockReference {
|
||||
kind,
|
||||
target_id: target_id.into(),
|
||||
label,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_block_type(mut self, block_type: BlockType) -> Self {
|
||||
self.block_type = block_type;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct DocumentModel {
|
||||
blocks: Vec<DocumentBlock>,
|
||||
}
|
||||
|
||||
impl DocumentModel {
|
||||
pub fn new(blocks: Vec<DocumentBlock>) -> Self {
|
||||
Self { blocks }
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn blocks(&self) -> &[DocumentBlock] {
|
||||
&self.blocks
|
||||
}
|
||||
|
||||
pub(crate) fn blocks_mut(&mut self) -> &mut Vec<DocumentBlock> {
|
||||
&mut self.blocks
|
||||
}
|
||||
|
||||
pub fn push(&mut self, block: DocumentBlock) {
|
||||
self.blocks.push(block);
|
||||
}
|
||||
|
||||
pub fn block(&self, id: &str) -> Option<&DocumentBlock> {
|
||||
self.blocks.iter().find(|block| block.id == id)
|
||||
}
|
||||
|
||||
pub(crate) fn block_mut(&mut self, id: &str) -> Option<&mut DocumentBlock> {
|
||||
self.blocks.iter_mut().find(|block| block.id == id)
|
||||
}
|
||||
|
||||
pub fn contains_id(&self, id: &str) -> bool {
|
||||
self.block(id).is_some()
|
||||
}
|
||||
|
||||
pub fn index_of(&self, id: &str) -> Option<usize> {
|
||||
self.blocks.iter().position(|block| block.id == id)
|
||||
}
|
||||
|
||||
pub fn children_of<'a>(&'a self, parent_id: Option<&str>) -> Vec<&'a DocumentBlock> {
|
||||
self.blocks
|
||||
.iter()
|
||||
.filter(|block| block.parent_id.as_deref() == parent_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_descendant_of(&self, block_id: &str, ancestor_id: &str) -> bool {
|
||||
let mut current_parent = self
|
||||
.block(block_id)
|
||||
.and_then(|block| block.parent_id.as_deref());
|
||||
while let Some(parent_id) = current_parent {
|
||||
if parent_id == ancestor_id {
|
||||
return true;
|
||||
}
|
||||
current_parent = self
|
||||
.block(parent_id)
|
||||
.and_then(|parent| parent.parent_id.as_deref());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn subtree_range(&self, block_id: &str) -> Option<RangeInclusive<usize>> {
|
||||
let start = self.index_of(block_id)?;
|
||||
let mut end = start;
|
||||
for next_index in (start + 1)..self.blocks.len() {
|
||||
if self.is_descendant_of(&self.blocks[next_index].id, block_id) {
|
||||
end = next_index;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(start..=end)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use crate::command::{CommandExecutor, EditorCommand};
|
||||
use crate::error::{CoreError, CoreResult};
|
||||
use crate::markdown::{import_markdown, import_plain_text};
|
||||
use crate::model::{BlockType, DocumentBlock, DocumentModel};
|
||||
use crate::projection::VisibilitySnapshot;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditorInputKind {
|
||||
PlainText,
|
||||
Markdown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditorAiScenario {
|
||||
MeetingNotesToTodos,
|
||||
LongParagraphToTitle,
|
||||
PageReorder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorPipelineRequest {
|
||||
pub kind: EditorInputKind,
|
||||
pub scenario: EditorAiScenario,
|
||||
pub input: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockSnapshot {
|
||||
pub block_id: String,
|
||||
pub block_type: String,
|
||||
pub text: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub indent: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandAuditRecord {
|
||||
pub command: String,
|
||||
pub target_block_id: Option<String>,
|
||||
pub before: Option<BlockSnapshot>,
|
||||
pub after: Option<BlockSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorChangeReport {
|
||||
pub created_blocks: Vec<String>,
|
||||
pub updated_blocks: Vec<String>,
|
||||
pub moved_blocks: Vec<String>,
|
||||
pub removed_blocks: Vec<String>,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorPipelineResult {
|
||||
pub ok: bool,
|
||||
pub scenario: EditorAiScenario,
|
||||
pub input_kind: EditorInputKind,
|
||||
pub document: DocumentModel,
|
||||
pub audit: Vec<CommandAuditRecord>,
|
||||
pub change_report: EditorChangeReport,
|
||||
pub visible_block_ids: Vec<String>,
|
||||
pub outline_titles: Vec<String>,
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
pub fn apply_ai_pipeline(request: EditorPipelineRequest) -> CoreResult<EditorPipelineResult> {
|
||||
let mut document = match request.kind {
|
||||
EditorInputKind::PlainText => import_plain_text(&request.input)?,
|
||||
EditorInputKind::Markdown => import_markdown(&request.input)?,
|
||||
};
|
||||
if document.blocks().is_empty() {
|
||||
document.push(DocumentBlock::new("block_1", BlockType::Paragraph).with_text(""));
|
||||
}
|
||||
|
||||
let mut audit = Vec::new();
|
||||
let mut change_report = EditorChangeReport {
|
||||
created_blocks: Vec::new(),
|
||||
updated_blocks: Vec::new(),
|
||||
moved_blocks: Vec::new(),
|
||||
removed_blocks: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
};
|
||||
|
||||
match request.scenario {
|
||||
EditorAiScenario::MeetingNotesToTodos => {
|
||||
apply_meeting_notes_to_todos(&mut document, &mut audit, &mut change_report)?
|
||||
}
|
||||
EditorAiScenario::LongParagraphToTitle => {
|
||||
apply_long_paragraph_to_title(&mut document, &mut audit, &mut change_report)?
|
||||
}
|
||||
EditorAiScenario::PageReorder => {
|
||||
apply_page_reorder(&mut document, &mut audit, &mut change_report)?
|
||||
}
|
||||
}
|
||||
|
||||
let projection = VisibilitySnapshot::derive(&document);
|
||||
let markdown = crate::markdown::export_markdown(&document);
|
||||
let outline_titles = projection
|
||||
.outline
|
||||
.iter()
|
||||
.map(|entry| entry.title.clone())
|
||||
.collect();
|
||||
Ok(EditorPipelineResult {
|
||||
ok: true,
|
||||
scenario: request.scenario,
|
||||
input_kind: request.kind,
|
||||
document,
|
||||
audit,
|
||||
change_report,
|
||||
visible_block_ids: projection.visible_block_ids,
|
||||
outline_titles,
|
||||
markdown,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_meeting_notes_to_todos(
|
||||
document: &mut DocumentModel,
|
||||
audit: &mut Vec<CommandAuditRecord>,
|
||||
report: &mut EditorChangeReport,
|
||||
) -> CoreResult<()> {
|
||||
for index in 0..document.blocks().len() {
|
||||
let is_note = document.blocks()[index].block_type == BlockType::Paragraph
|
||||
&& document.blocks()[index].content.text.contains('待')
|
||||
&& document.blocks()[index].content.text.contains('办');
|
||||
if is_note {
|
||||
let block_id = document.blocks()[index].id.clone();
|
||||
let before = snapshot_block(document.blocks()[index].clone());
|
||||
CommandExecutor::apply(
|
||||
document,
|
||||
EditorCommand::SetBlockType {
|
||||
block_id: block_id.clone(),
|
||||
block_type: BlockType::Todo,
|
||||
},
|
||||
)?;
|
||||
let block = document
|
||||
.block(&block_id)
|
||||
.ok_or_else(|| CoreError::BlockNotFound(block_id.clone()))?;
|
||||
let after = snapshot_block(block.clone());
|
||||
audit.push(CommandAuditRecord {
|
||||
command: "set_block_type".into(),
|
||||
target_block_id: Some(block_id.clone()),
|
||||
before: Some(before),
|
||||
after: Some(after),
|
||||
});
|
||||
report.updated_blocks.push(block_id);
|
||||
}
|
||||
}
|
||||
report.notes.push("已将疑似会议待办段落转为 todo".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_long_paragraph_to_title(
|
||||
document: &mut DocumentModel,
|
||||
audit: &mut Vec<CommandAuditRecord>,
|
||||
report: &mut EditorChangeReport,
|
||||
) -> CoreResult<()> {
|
||||
let Some(first_id) = document.blocks().first().map(|block| block.id.clone()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let before = snapshot_block(
|
||||
document
|
||||
.block(&first_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(first_id.clone()))?,
|
||||
);
|
||||
let title = document
|
||||
.block(&first_id)
|
||||
.map(|block| {
|
||||
block
|
||||
.content
|
||||
.text
|
||||
.split_whitespace()
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
CommandExecutor::apply(
|
||||
document,
|
||||
EditorCommand::SetBlockType {
|
||||
block_id: first_id.clone(),
|
||||
block_type: BlockType::Heading,
|
||||
},
|
||||
)?;
|
||||
if let Some(block) = document.block_mut(&first_id) {
|
||||
block.heading_level = Some(1);
|
||||
block.content.text = if title.is_empty() {
|
||||
"提炼标题".into()
|
||||
} else {
|
||||
title
|
||||
};
|
||||
}
|
||||
let after = snapshot_block(
|
||||
document
|
||||
.block(&first_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(first_id.clone()))?,
|
||||
);
|
||||
audit.push(CommandAuditRecord {
|
||||
command: "set_block_type".into(),
|
||||
target_block_id: Some(first_id.clone()),
|
||||
before: Some(before),
|
||||
after: Some(after),
|
||||
});
|
||||
report.updated_blocks.push(first_id);
|
||||
report.notes.push("已将长段落提炼为标题".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_page_reorder(
|
||||
document: &mut DocumentModel,
|
||||
audit: &mut Vec<CommandAuditRecord>,
|
||||
report: &mut EditorChangeReport,
|
||||
) -> CoreResult<()> {
|
||||
if document.blocks().len() < 2 {
|
||||
report.notes.push("块数不足,跳过重排".into());
|
||||
return Ok(());
|
||||
}
|
||||
let first = document.blocks()[0].id.clone();
|
||||
let second = document.blocks()[1].id.clone();
|
||||
let before_first = snapshot_block(
|
||||
document
|
||||
.block(&first)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(first.clone()))?,
|
||||
);
|
||||
CommandExecutor::apply(
|
||||
document,
|
||||
EditorCommand::MoveBlock {
|
||||
block_id: first.clone(),
|
||||
after_block_id: Some(second.clone()),
|
||||
},
|
||||
)?;
|
||||
let after_first = snapshot_block(
|
||||
document
|
||||
.block(&first)
|
||||
.cloned()
|
||||
.ok_or_else(|| CoreError::BlockNotFound(first.clone()))?,
|
||||
);
|
||||
audit.push(CommandAuditRecord {
|
||||
command: "move_block".into(),
|
||||
target_block_id: Some(first.clone()),
|
||||
before: Some(before_first),
|
||||
after: Some(after_first),
|
||||
});
|
||||
report.moved_blocks.push(first);
|
||||
report.notes.push("已执行页面重排".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_block(block: DocumentBlock) -> BlockSnapshot {
|
||||
BlockSnapshot {
|
||||
block_id: block.id,
|
||||
block_type: block.block_type.as_editor_label().into(),
|
||||
text: block.content.text,
|
||||
parent_id: block.parent_id,
|
||||
indent: block.indent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::model::{BlockType, DocumentBlock, DocumentModel};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OutlineEntry {
|
||||
pub block_id: String,
|
||||
pub level: u8,
|
||||
pub title: String,
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VisibilitySnapshot {
|
||||
pub visible_block_ids: Vec<String>,
|
||||
pub outline: Vec<OutlineEntry>,
|
||||
}
|
||||
|
||||
impl VisibilitySnapshot {
|
||||
pub fn derive(document: &DocumentModel) -> Self {
|
||||
let mut hidden_ancestor_ids: Vec<String> = Vec::new();
|
||||
let mut visible_block_ids = Vec::new();
|
||||
let mut outline = Vec::new();
|
||||
|
||||
for block in document.blocks() {
|
||||
hidden_ancestor_ids.retain(|ancestor_id| is_ancestor_of(document, ancestor_id, block));
|
||||
let visible = hidden_ancestor_ids.is_empty();
|
||||
if visible {
|
||||
visible_block_ids.push(block.id.clone());
|
||||
}
|
||||
|
||||
if matches!(block.block_type, BlockType::Heading) {
|
||||
let level = block.heading_level.unwrap_or(1);
|
||||
if visible {
|
||||
outline.push(OutlineEntry {
|
||||
block_id: block.id.clone(),
|
||||
level,
|
||||
title: block.content.text.clone(),
|
||||
depth: block.indent as usize,
|
||||
});
|
||||
}
|
||||
if block.collapsed {
|
||||
hidden_ancestor_ids.push(block.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
visible_block_ids,
|
||||
outline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ancestor_of(document: &DocumentModel, ancestor_id: &str, block: &DocumentBlock) -> bool {
|
||||
document.is_descendant_of(&block.id, ancestor_id)
|
||||
}
|
||||
@@ -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