mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 18:54:57 +00:00
Merge pull request #5922 from Hamza0101/fix/find-whole-word-across-runs
Find Whole Word: a change of style inside a word is not a word boundary (#5901)
This commit is contained in:
@@ -625,6 +625,22 @@ fn is_word(c: char) -> bool {
|
||||
|
||||
/// Byte ranges of `needle` in `hay`.
|
||||
pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> {
|
||||
find_all_between(hay, needle, match_case, whole_word, None, None)
|
||||
}
|
||||
|
||||
/// [`find_all`] in run `i` of `runs`: a whole word looks past the run's ends at the characters
|
||||
/// next to it, so a change of style inside a word is not a word boundary (#5901). Matches stay
|
||||
/// inside the run.
|
||||
fn find_in_run(runs: &[TextRun], i: usize, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> {
|
||||
let Some(run) = runs.get(i) else { return vec![] };
|
||||
let before = runs.get(..i).and_then(|r| r.iter().rev().find_map(|r| r.text.chars().next_back()));
|
||||
let after = runs.get(i + 1..).and_then(|r| r.iter().find_map(|r| r.text.chars().next()));
|
||||
find_all_between(&run.text, needle, match_case, whole_word, before, after)
|
||||
}
|
||||
|
||||
/// [`find_all`], with `before` and `after` the characters on either side of `hay` (none at the
|
||||
/// text's ends), which a whole word must not touch either.
|
||||
fn find_all_between(hay: &str, needle: &str, match_case: bool, whole_word: bool, before: Option<char>, after: Option<char>) -> Vec<(usize, usize)> {
|
||||
let nc: Vec<char> = needle.chars().collect();
|
||||
if nc.is_empty() {
|
||||
return vec![];
|
||||
@@ -635,9 +651,9 @@ pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bo
|
||||
let mut i = 0;
|
||||
while i + nc.len() <= hc.len() {
|
||||
if (0..nc.len()).all(|k| eq(hc[i + k].1, nc[k])) {
|
||||
let before = i.checked_sub(1).map(|j| hc[j].1);
|
||||
let after = hc.get(i + nc.len()).map(|x| x.1);
|
||||
if !whole_word || (!before.is_some_and(is_word) && !after.is_some_and(is_word)) {
|
||||
let prev = i.checked_sub(1).map_or(before, |j| hc.get(j).map(|x| x.1));
|
||||
let next = hc.get(i + nc.len()).map_or(after, |x| Some(x.1));
|
||||
if !whole_word || (!prev.is_some_and(is_word) && !next.is_some_and(is_word)) {
|
||||
let start = hc[i].0;
|
||||
let end = hc.get(i + nc.len()).map(|x| x.0).unwrap_or(hay.len());
|
||||
out.push((start, end));
|
||||
@@ -685,7 +701,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let total: usize = ids
|
||||
.iter()
|
||||
.filter_map(|id| match &st.doc.node(*id)?.kind {
|
||||
NodeKind::Text(t) => Some(t.runs.iter().map(|r| find_all(&r.text, &find, mc, ww).len()).sum::<usize>()),
|
||||
NodeKind::Text(t) => Some((0..t.runs.len()).map(|i| find_in_run(&t.runs, i, &find, mc, ww).len()).sum::<usize>()),
|
||||
_ => None,
|
||||
})
|
||||
.sum();
|
||||
@@ -702,7 +718,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) {
|
||||
for (a, b) in find_in_run(&t.runs, ri, &find, mc, ww) {
|
||||
if r.text.get(a..b) != Some(rep.as_str()) {
|
||||
hits.push((ri, base + a, base + b, a, b));
|
||||
}
|
||||
|
||||
@@ -1989,6 +1989,36 @@ fn find_and_replace_options() {
|
||||
assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err());
|
||||
}
|
||||
|
||||
/// Whole Word looks past a change of style: `cat` coloured red inside `catfish`, or at the end of
|
||||
/// `bobcat`, is part of a word, not a word of its own (#5901). A real word next to a run's end
|
||||
/// still matches, and without Whole Word the part is replaced as before.
|
||||
#[test]
|
||||
fn find_whole_word_is_not_fooled_by_a_style_change_inside_a_word() {
|
||||
let styled = |s: &mut Session, t: &str, a: usize, b: usize| {
|
||||
let id = text(s, t);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": a, "end": b, "fill": "#ff0000"})).unwrap();
|
||||
let NodeKind::Text(tt) = &node(s, id).kind else { panic!() };
|
||||
assert!(tt.runs.len() >= 2, "{t}: two runs");
|
||||
id
|
||||
};
|
||||
let mut s = session();
|
||||
let fish = styled(&mut s, "catfish", 0, 3);
|
||||
let bob = styled(&mut s, "bobcat", 3, 6);
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG", "wholeWord": true})).unwrap();
|
||||
assert_eq!((r["count"].as_u64(), plain(&s, fish), plain(&s, bob)), (Some(0), "catfish".to_string(), "bobcat".to_string()));
|
||||
// A word that ends where its run does, at a space or the text's end, is still a word.
|
||||
let mut s = session();
|
||||
let spaced = styled(&mut s, "cat fish", 0, 3);
|
||||
let tail = styled(&mut s, "a cat", 2, 5);
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG", "wholeWord": true})).unwrap();
|
||||
assert_eq!((r["count"].as_u64(), plain(&s, spaced), plain(&s, tail)), (Some(2), "DOG fish".to_string(), "a DOG".to_string()));
|
||||
// Without Whole Word, the part inside the word is found as before.
|
||||
let mut s = session();
|
||||
let fish = styled(&mut s, "catfish", 0, 3);
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG"})).unwrap();
|
||||
assert_eq!((r["count"].as_u64(), plain(&s, fish)), (Some(1), "DOGfish".to_string()));
|
||||
}
|
||||
|
||||
/// Find and Replace with no ids leaves the type on a locked artboard as it is.
|
||||
#[test]
|
||||
fn find_and_replace_leaves_type_on_a_locked_artboard() {
|
||||
|
||||
Reference in New Issue
Block a user