Files
designcraft/crates/engine/src/cmd/endnotes.rs
T
dotCooCoo db935e2090 Footnotes: deleting a footnote or endnote keeps the caret on the same text
footnote.delete and endnote.delete removed the reference character from
the story but left a caret or selection in the story text at its old
byte offsets. Everything after the reference moved back by the
reference's length, so the caret ended up three bytes further along the
text. A caret or selection after the reference now moves back with the
text, and one before it stays where it is.

footnote.setText left a caret in the replaced footnote at its old
offset, which could fall past the end of the new text or inside a
character. Backspace there then failed with an internal error. The
caret now goes to the end of the new text.
2026-10-24 09:11:53 -07:00

194 lines
9.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Type › Insert Endnote and Document Endnote Options. Endnote text lives with its reference;
//! the endnote frame (made on a new last page with the first endnote) lists them all.
use designcraft_doc::{ParaFormat, SpreadRef, StoryId};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"endnote.insert",
"Insert Endnote",
["Type"],
None,
"{text?} — a reference at the insertion point; the first one makes the endnote frame on a new last page → {story, id, frame}",
has_text,
insert
),
cmd!("endnote.edit", "Edit Endnote", [], None, "{story, id, text}", has_doc, |s, p| {
let (sid, id) = target(p)?;
let text = super::text_param(p, "text");
s.edit(|d, _| {
let style = d.endnote_options.para_style.clone();
let st = d.story_mut(sid).ok_or_else(|| bad("endnote.edit", "no such story"))?;
let n = st.endnote_mut(id).ok_or_else(|| bad("endnote.edit", format!("no endnote {id}")))?;
n.text = designcraft_doc::Story::with_text(StoryId(0), &text, ParaFormat { style, ..Default::default() });
ok()
})
}),
cmd!("endnote.delete", "Delete Endnote", [], None, "{story, id} — removes the reference and its text", has_doc, |s, p| {
let (sid, id) = target(p)?;
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or_else(|| bad("endnote.delete", "no such story"))?;
let k = st.endnotes.iter().position(|n| n.id == id).ok_or_else(|| bad("endnote.delete", format!("no endnote {id}")))?;
let at = st
.text
.match_indices(designcraft_doc::ENDNOTE_REF)
.nth(k)
.map(|(i, _)| i)
.ok_or_else(|| bad("endnote.delete", format!("endnote {id} has no reference in the text")))?;
super::notes::follow_delete(sel, sid, at..at + designcraft_doc::ENDNOTE_REF.len_utf8());
st.delete(at..at + designcraft_doc::ENDNOTE_REF.len_utf8());
ok()
})
}),
cmd!(query "endnote.list", "Endnotes", [], None, "{} → [{story, id, number, text}] in numbering order", has_doc, |s, _| {
let d = &s.doc()?.doc;
let mut out = Vec::new();
for st in d.stories.values().filter(|st| !st.endnotes.is_empty()) {
let first = d.endnote_start(st.id);
for (k, n) in st.endnotes.iter().enumerate() {
out.push(json!({"story": st.id.0, "id": n.id, "number": first.saturating_add(k as u32), "text": n.text.text}));
}
}
out.sort_by_key(|v| v["number"].as_u64());
Ok(Value::Array(out))
}),
cmd!(
"endnote.options",
"Document Endnote Options…",
["Type"],
None,
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|kanji, startAt?, prefix?, suffix?, heading?, headingStyle?, paraStyle?, separator?} → the options",
has_doc,
|s, p| {
let cur = serde_json::to_value(&s.doc()?.doc.endnote_options).map_err(|e| bad("endnote.options", e.to_string()))?;
let mut new = cur.clone();
if let (Some(n), Some(o)) = (new.as_object_mut(), p.as_object()) {
for (k, v) in o {
if !n.contains_key(k) {
return Err(bad("endnote.options", format!("unknown option {k}")));
}
n.insert(k.clone(), v.clone());
}
}
let opts: designcraft_doc::EndnoteOptions = serde_json::from_value(new.clone()).map_err(|e| bad("endnote.options", e.to_string()))?;
if new != cur {
s.edit(|d, _| {
d.endnote_options = opts;
ok()
})?;
}
Ok(new)
}
),
]
}
fn target(p: &Value) -> Result<(StoryId, u64)> {
let sid = p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("endnote", "`story` required"))?;
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("endnote", "`id` required"))?;
Ok((StoryId(sid), id))
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "endnote.insert";
let st = s.doc()?;
let ts = st.selection.text.ok_or_else(|| bad(ID, "place the insertion point in text"))?;
if ts.cell.is_some() {
return Err(bad(ID, "endnotes go in story text, not tables or footnotes"));
}
if st.doc.endnote_story == Some(ts.story) {
return Err(bad(ID, "the endnote frame lists the endnotes"));
}
let text = super::text_param(p, "text");
let at = ts.anchor.max(ts.focus);
s.edit(|d, sel| {
// The endnote frame: a text frame in the margins of a new last page.
let mut frame = None;
if d.endnote_story.is_none() {
let last = d.page_count().saturating_sub(1);
let parent = d.page_loc(last).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
d.insert_pages(Some(last), 1, parent)?;
let (si, pi) = d.page_loc(last + 1).ok_or_else(|| bad(ID, "page insert failed"))?;
let r = d.spreads[si].pages[pi].margin_rect();
let layer = d.default_layer();
let (fid, esid) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?;
d.endnote_story = Some(esid);
frame = Some(fid.0);
}
let style = d.endnote_options.para_style.clone();
let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?;
let id = story
.insert_endnote(at, &text, ParaFormat { style, ..Default::default() })
.ok_or_else(|| bad(ID, "the story's endnotes don't match its references"))?;
let caret = at + designcraft_doc::ENDNOTE_REF.len_utf8();
if let Some(t) = &mut sel.text {
(t.anchor, t.focus) = (caret, caret);
}
Ok(json!({"story": ts.story.0, "id": id, "frame": frame}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn endnotes_number_through_the_document_and_fill_the_endnote_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Alpha beta."})).unwrap();
s.execute("text.select", &json!({"story": a["story"], "anchor": 5, "focus": 5})).unwrap();
let r1 = s.execute("endnote.insert", &json!({"text": "First source."})).unwrap();
let pages = s.doc().unwrap().doc.page_count();
assert_eq!(pages, 3, "a new last page holds the endnote frame");
assert!(r1["frame"].is_u64());
s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap();
let r0 = s.execute("endnote.insert", &json!({"text": "Earlier."})).unwrap();
assert!(r0["frame"].is_null(), "one endnote frame");
let list = s.execute("endnote.list", &json!({})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 2);
let d = s.doc().unwrap().doc.clone();
let esid = d.endnote_story.unwrap();
assert_eq!(d.stories[&esid].text, "Endnotes\n1\tEarlier.\n2\tFirst source.", "numbered in text order");
// The references are numbered in the composed text.
let sid = designcraft_doc::StoryId(a["story"].as_u64().unwrap());
let cs = s.cache.get(&d, sid, None);
let shown = cs.frames[0].lines[0].glyphs.len();
assert!(shown > "Alpha beta.".chars().count(), "reference numbers take glyphs");
s.execute("endnote.edit", &json!({"story": r1["story"], "id": r1["id"], "text": "Edited."})).unwrap();
s.execute("endnote.options", &json!({"style": "lowerRoman", "heading": "Notes"})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert_eq!(d.stories[&esid].text, "Notes\ni\tEarlier.\nii\tEdited.");
s.execute("endnote.delete", &json!({"story": r0["story"], "id": r0["id"]})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert_eq!(d.stories[&esid].text, "Notes\ni\tEdited.");
assert_eq!(d.stories[&sid].text.matches(designcraft_doc::ENDNOTE_REF).count(), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.stories[&esid].text, "Notes\ni\tEarlier.\nii\tEdited.");
}
#[test]
fn deleting_an_endnote_keeps_the_caret_on_its_character() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Alpha beta."})).unwrap();
let sid = designcraft_doc::StoryId(a["story"].as_u64().unwrap());
s.execute("text.select", &json!({"story": a["story"], "anchor": 5, "focus": 5})).unwrap();
let r = s.execute("endnote.insert", &json!({"text": "Source."})).unwrap();
let n = designcraft_doc::ENDNOTE_REF.len_utf8();
// "beta" is selected after the reference.
s.execute("text.select", &json!({"story": a["story"], "anchor": 6 + n, "focus": 10 + n})).unwrap();
s.execute("endnote.delete", &json!({"story": r["story"], "id": r["id"]})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (6, 10));
assert_eq!(&s.doc().unwrap().doc.stories[&sid].text[t.anchor..t.focus], "beta");
}
}