Files
Ely-GPUI-Components/src/documents/document/block/split.rs
T
ZacharyZhang-NYandClaude Opus 5.5 b37031b00b documents: block commands keep text, tasks and list items whole
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>
2026-10-14 14:08:40 -04:00

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))
}