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.
This commit is contained in:
dotCooCoo
2026-10-24 11:45:19 -07:00
parent 45ed6cfce0
commit 28d3c6b44f
+42 -2
View File
@@ -65,9 +65,10 @@ pub fn specs() -> Vec<CommandSpec> {
}),
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<CommandSpec> {
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<usize> {
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<usize>, 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");
}
}
}
}