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,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
}
}
+20
View File
@@ -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}"),
}
}
+231
View File
@@ -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)
}