Keep paragraph styles aligned in Find and Replace with line breaks

Found while testing crates/engine/src/cmd/typemenu.rs:668 (find_replace / edit_runs)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-16 14:57:20 +07:00
committed by bflatastic
co-authored by Claude Haiku 5.5
parent f14f8d8eb7
commit ee6cd6de55
2 changed files with 76 additions and 9 deletions
+32 -9
View File
@@ -665,16 +665,39 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
if total == 0 {
return Ok(json!({ "count": 0 }));
}
edit_runs::<()>(s, "Replace", &ids, |text, _| {
let mut out = String::with_capacity(text.len());
let mut last = 0;
for (a, b) in find_all(text, &find, mc, ww) {
out.push_str(&text[last..a]);
out.push_str(&rep);
last = b;
super::overprint::edit_counted(s, "Replace", |d| {
let mut changed = 0;
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
// Matches as (run, start, end) with offsets inside the run. Inline graphics are art, not
// characters to rewrite.
let mut hits = vec![];
let mut base = 0;
for (ri, r) in t.runs.iter().enumerate() {
if r.inline.is_none() {
for (a, b) in find_all(&r.text, &find, mc, ww) {
if r.text.get(a..b) != Some(rep.as_str()) {
hits.push((ri, base + a, base + b, a, b));
}
}
}
base += r.text.len();
}
if hits.is_empty() {
continue;
}
// Last match first, so the offsets of earlier ones stay valid. The paragraph styles follow
// each edit, as `text.editRange` does.
for (ri, ta, tb, a, b) in hits.into_iter().rev() {
t.splice_paras(ta, tb, &rep);
if let Some(r) = t.runs.get_mut(ri) {
r.text.replace_range(a..b, &rep);
}
}
changed += 1;
refresh_bounds(t);
}
out.push_str(&text[last..]);
out
Ok(changed)
})?;
Ok(json!({ "count": total }))
}
+44
View File
@@ -0,0 +1,44 @@
//! `edit.findReplace` keeps per-paragraph styles lined up with the text when a find or replace
//! string holds a line break, as `text.editRange` does.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::json;
use vectorcraft_doc::{Justify, NodeId, NodeKind};
use vectorcraft_testkit::fixtures::{self, exec, id_of};
/// "A\nB\nC" with B centred and C right-aligned.
fn setup() -> (vectorcraft_engine::Session, NodeId) {
let mut s = fixtures::session_with(400.0, 300.0);
let id = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "A\nB\nC", "size": 20})));
exec(&mut s, "text.setStyle", json!({"id": id.0, "start": 2, "end": 3, "justify": "center"}));
exec(&mut s, "text.setStyle", json!({"id": id.0, "start": 4, "end": 5, "justify": "right"}));
exec(&mut s, "select.none", json!({}));
(s, id)
}
fn state(s: &vectorcraft_engine::Session, id: NodeId) -> (String, Vec<Justify>) {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (t.plain_text(), t.paragraph_styles().iter().map(|p| p.justify).collect()),
_ => panic!("not text"),
}
}
#[test]
fn replacement_adding_a_line_break_keeps_later_paragraph_styles() {
let (mut s, id) = setup();
assert_eq!(state(&s, id).1, [Justify::Auto, Justify::Center, Justify::Right]);
exec(&mut s, "edit.findReplace", json!({"find": "A", "replace": "A\nA2", "ids": [id.0]}));
let (text, styles) = state(&s, id);
assert_eq!(text, "A\nA2\nB\nC");
assert_eq!(styles, [Justify::Auto, Justify::Auto, Justify::Center, Justify::Right]);
}
#[test]
fn find_text_with_a_line_break_merges_paragraphs_like_edit_range() {
let (mut s, id) = setup();
exec(&mut s, "edit.findReplace", json!({"find": "A\nB", "replace": "AB", "ids": [id.0]}));
let (text, styles) = state(&s, id);
assert_eq!(text, "AB\nC");
assert_eq!(styles, [Justify::Auto, Justify::Right]);
}