From 28d3c6b44fcdb81ca8356dbfc328dd15d6b6046e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:45:19 -0700 Subject: [PATCH] Notes: deleting a note or converting it to text keeps the caret on the same text note.delete removes the note's anchor (3 bytes) from the story, and note.convertToText replaces it with the note's text. Both left a caret or selection in the story text at its old byte offsets. With "copy" selected after a note in "Draft copy.", Delete Note left the selection at 9..13 in an 11-byte story, and Convert Note to Text with the note's text "ab" left "opy." selected. Both commands now move a caret or selection in the story's own text by the change in length when it comes after the anchor. A caret before the anchor stays where it is, and a caret in a table cell or in another story is unchanged. --- crates/engine/src/cmd/editorial.rs | 44 ++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 564816ee8..03c5abd1a 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -65,9 +65,10 @@ pub fn specs() -> Vec { }), cmd!("note.delete", "Delete Note", ["Type", "Notes"], None, "{story, id}", has_doc, |s, p| { let (sid, id) = target(p)?; - s.edit(|d, _| { + s.edit(|d, sel| { let st = d.story_mut(sid).ok_or_else(|| bad("note.delete", "no such story"))?; let at = anchor(st, id).ok_or_else(|| bad("note.delete", format!("no note {id}")))?; + follow_replace(sel, sid, at..at + NOTE_MARK.len_utf8(), 0); st.delete(at..at + NOTE_MARK.len_utf8()); ok() }) @@ -81,10 +82,11 @@ pub fn specs() -> Vec { has_doc, |s, p| { let (sid, id) = target(p)?; - s.edit(|d, _| { + s.edit(|d, sel| { let st = d.story_mut(sid).ok_or_else(|| bad("note.convertToText", "no such story"))?; let at = anchor(st, id).ok_or_else(|| bad("note.convertToText", format!("no note {id}")))?; let text = st.editorial.iter().find(|n| n.id == id).map(|n| n.text.clone()).unwrap_or_default(); + follow_replace(sel, sid, at..at + NOTE_MARK.len_utf8(), text.len()); st.replace(at..at + NOTE_MARK.len_utf8(), &text); ok() }) @@ -184,6 +186,16 @@ fn anchor(st: &designcraft_doc::Story, id: u64) -> Option { st.text.match_indices(NOTE_MARK).nth(k).map(|(i, _)| i) } +/// Keeps a caret or selection in story `sid`'s own text (not a cell) on the same characters when `range` is replaced +/// by `len` bytes: an end after the range moves by the change in length, and an end inside it moves to its start. +fn follow_replace(sel: &mut designcraft_doc::Selection, sid: StoryId, range: std::ops::Range, len: usize) { + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + let follow = |i: usize| if i >= range.end { i - range.len() + len } else { i.min(range.start) }; + t.anchor = follow(t.anchor); + t.focus = follow(t.focus); + } +} + /// The anchor offset and id of the note before or after the selection `t` in its story. Going /// back skips the note whose anchor the caret directly follows, so repeated steps reach every note. fn neighbor(st: &designcraft_doc::Story, t: &designcraft_doc::TextSel, forward: bool) -> Option<(usize, u64)> { @@ -377,4 +389,32 @@ mod tests { assert!(s.doc().unwrap().selection.text.is_some_and(|t| t.cell.is_some() && !t.is_caret())); assert!(disabled(s.execute("note.convertFromText", &json!({}))), "notes go in story text"); } + + #[test] + fn deleting_or_converting_a_note_keeps_the_caret_on_its_character() { + let m = designcraft_doc::NOTE_MARK.len_utf8(); + // (anchor, focus) in "Draft{note} copy." before the command, then (anchor, focus) and the selected text after + // Delete Note and after Convert Note to Text with the note's text "ab". + let cases = [ + ((6 + m, 10 + m), ((6, 10), "copy"), ((8, 12), "copy")), + ((0, 10 + m), ((0, 10), "Draft copy"), ((0, 12), "Draftab copy")), + ((2, 4), ((2, 4), "af"), ((2, 4), "af")), + ((5 + m, 5 + m), ((5, 5), ""), ((7, 7), "")), + ]; + for (before, deleted, converted) in cases { + for (cmd, (after, selected)) in [("note.delete", deleted), ("note.convertToText", converted)] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap(); + let n = s.execute("note.new", &json!({"text": "ab"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": before.0, "focus": before.1})).unwrap(); + s.execute(cmd, &json!({"story": n["story"], "id": n["id"]})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), after, "{cmd} with {before:?} selected"); + assert_eq!(&s.doc().unwrap().doc.stories[&sid].text[t.anchor..t.focus], selected, "{cmd} with {before:?} selected"); + } + } + } }