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