Round-1 fixes for R4.7: a caret in heading marks splits at the text; a split closes and reopens bold, italic and strike, refusing inside links, code, math and HTML; a task's first text takes no block; retype keeps task boxes and code indent and takes siblings past the list; an alert in an item keeps the item's opener; paragraph-before moves a holder's first text below its opener; delete keeps neighbors from joining; a heading's text keeps closing hashes and block openers escaped; a heading level is idempotent, as Typora's menu calls it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
177 lines
6.0 KiB
Rust
177 lines
6.0 KiB
Rust
//! A paragraph or heading split at the caret, as Typora's `splitAt`.
|
|
|
|
use std::ops::Range;
|
|
|
|
use markdown::mdast::Node;
|
|
|
|
use super::{Here, inside, line_end, line_start, next_line, placed, prev_line, task_head};
|
|
use crate::documents::{
|
|
document::format::{Change, EditRefusal, marks},
|
|
markdown::span,
|
|
};
|
|
|
|
/// Where the new block's text goes, ending the edit.
|
|
pub(super) struct Room<'a> {
|
|
pub(super) nl: &'static str,
|
|
pub(super) lead: String,
|
|
pub(super) blank: String,
|
|
pub(super) containers: Vec<&'a Node>,
|
|
}
|
|
|
|
impl Room<'_> {
|
|
/// A blank line, then a new line's prefix.
|
|
pub(super) fn gap(&self) -> String {
|
|
format!("{}{}{}{}", self.nl, self.blank, self.nl, self.lead)
|
|
}
|
|
}
|
|
|
|
/// Where `line`'s text starts inside `containers`; a lazy line's own.
|
|
pub(super) fn content(text: &str, line: usize, containers: &[&Node]) -> usize {
|
|
let at = inside(text, line, containers).unwrap_or(line);
|
|
at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len()
|
|
}
|
|
|
|
/// Back from `at` over blanks and prefixes, to `floor`.
|
|
fn back(text: &str, mut at: usize, floor: usize, containers: &[&Node]) -> usize {
|
|
loop {
|
|
let line = line_start(text, at).max(floor);
|
|
at = line + text[line..at].trim_end_matches([' ', '\t']).len();
|
|
if line == floor || at > content(text, line, containers) {
|
|
return at;
|
|
}
|
|
at = line_end(text, prev_line(text, line).expect("a line above the floor"));
|
|
}
|
|
}
|
|
|
|
/// On from `at` over blanks and prefixes, to `ceil`.
|
|
fn forth(text: &str, mut at: usize, ceil: usize, containers: &[&Node]) -> usize {
|
|
loop {
|
|
let end = line_end(text, at).min(ceil);
|
|
at = end - text[at..end].trim_start_matches([' ', '\t']).len();
|
|
if at < end {
|
|
return at;
|
|
}
|
|
match next_line(text, at) {
|
|
Some(next) if next < ceil => at = content(text, next, containers),
|
|
_ => return ceil,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inline nodes with no text to cut.
|
|
fn sealed(node: &Node) -> bool {
|
|
matches!(
|
|
node,
|
|
Node::InlineMath(_) | Node::Html(_) | Node::FootnoteReference(_)
|
|
)
|
|
}
|
|
|
|
/// `at` off inline edges; the marks to close and to reopen.
|
|
fn seam(here: &Here, mut at: usize) -> Result<(usize, String, String), EditRefusal> {
|
|
let text = here.text;
|
|
let marked: Vec<(&Node, [Range<usize>; 2])> = (here.nodes.iter())
|
|
.filter_map(|node| Some((*node, marks(node, text)?)))
|
|
.collect();
|
|
while let Some([open, _]) = (marked.iter())
|
|
.map(|(_, holes)| holes)
|
|
.find(|[open, _]| open.start < at && open.end == at)
|
|
{
|
|
at = open.start;
|
|
}
|
|
while let Some([_, close]) = (marked.iter())
|
|
.map(|(_, holes)| holes)
|
|
.find(|[_, close]| close.start == at && at < close.end)
|
|
{
|
|
at = close.end;
|
|
}
|
|
let strictly = |range: Range<usize>| range.start < at && at < range.end;
|
|
let mut cut: Vec<&(&Node, [Range<usize>; 2])> = (marked.iter())
|
|
.filter(|(node, _)| strictly(span(node)))
|
|
.collect();
|
|
let paired =
|
|
|node: &Node| matches!(node, Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_));
|
|
let unsafe_cut = (here.nodes.iter()).any(|node| sealed(node) && strictly(span(node)))
|
|
|| (cut.iter()).any(|(node, [open, close])| {
|
|
!paired(node) || strictly(open.clone()) || strictly(close.clone())
|
|
});
|
|
if unsafe_cut {
|
|
return Err(EditRefusal::Across);
|
|
}
|
|
cut.sort_by_key(|(node, _)| span(node).start);
|
|
let close = (cut.iter().rev()).map(|(_, [_, close])| &text[close.clone()]);
|
|
let open = cut.iter().map(|(_, [open, _])| &text[open.clone()]);
|
|
Ok((at, close.collect(), open.collect()))
|
|
}
|
|
|
|
/// `block` put where the selection splits `node`.
|
|
pub(super) fn split(
|
|
here: &Here,
|
|
node: &Node,
|
|
room: &Room,
|
|
block: &str,
|
|
inner: Option<usize>,
|
|
) -> Result<Change, EditRefusal> {
|
|
let text = here.text;
|
|
let whole = span(node);
|
|
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
|
let (marks, body) = match node {
|
|
Node::Heading(heading) => (
|
|
"#".repeat(usize::from(heading.depth)) + " ",
|
|
kids.first().map_or(whole.end, |kid| span(kid).start)
|
|
..kids.last().map_or(whole.end, |kid| span(kid).end),
|
|
),
|
|
_ => (String::new(), whole.clone()),
|
|
};
|
|
// A caret in heading marks takes the text's edge.
|
|
let s = here.selection.start.clamp(body.start, body.end);
|
|
let e = here.selection.end.clamp(body.start, body.end);
|
|
let (s, close, _) = seam(here, s)?;
|
|
let (e, _, open) = seam(here, e)?;
|
|
let containers = &room.containers;
|
|
let mut from = back(text, s, body.start, containers);
|
|
let after = forth(text, e, body.end, containers);
|
|
let before = from > body.start;
|
|
let follows = after < body.end;
|
|
// GFM reads a task's first line as text.
|
|
if !before && task_head(here.path[here.path.len() - 2], node) {
|
|
return Err(EditRefusal::Outside);
|
|
}
|
|
if !before {
|
|
from = whole.start;
|
|
}
|
|
let to = if follows { after } else { whole.end };
|
|
let gap = room.gap();
|
|
let mut written = String::new();
|
|
if before {
|
|
written.push_str(&close);
|
|
written.push_str(&gap);
|
|
}
|
|
let start = written.len();
|
|
written.push_str(block);
|
|
let lead = if follows { marks.len() + open.len() } else { 0 };
|
|
let caret = match inner {
|
|
Some(inner) => start + inner,
|
|
None => {
|
|
written.push_str(&gap);
|
|
written.len() + lead
|
|
}
|
|
};
|
|
if follows {
|
|
if inner.is_some() {
|
|
written.push_str(&gap);
|
|
}
|
|
written.push_str(&marks);
|
|
written.push_str(&open);
|
|
}
|
|
let mut edits = Vec::new();
|
|
let mut shift = 0;
|
|
if !before && here.joins_before(&whole, None) {
|
|
let line = line_start(text, whole.start);
|
|
let blank = format!("{}{}", room.blank, room.nl);
|
|
shift = blank.len();
|
|
edits.push((line..line, blank));
|
|
}
|
|
edits.push((from..to, written));
|
|
Ok(placed(edits, from + shift + caret))
|
|
}
|