Merge pull request #1785 from dotCooCoo/fix/editorial-note-keeps-caret

Notes: deleting a note or converting it to text keeps the caret on the same text
This commit is contained in:
Brandon Thomas
2026-10-25 12:26:20 -04:00
committed by GitHub
+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");
}
}
}
}