Files
designcraft/crates/engine/src/cmd/notes.rs
T
aiongg c403cae4c7 Merge #83 update (feat/layout-parity f386a98d): Em Box Height first baseline offset
Conflicts: fonts fontdb.rs (the branch's em box with its from-BASE flag beside next's ICF cache); idml
tests.rs (the branch's copies of tests already on next, and the mojikumi test #129 replaced, dropped;
the Em Box Height test kept). German entry for Em Box Height.
2026-10-10 21:16:13 +00:00

225 lines
9.9 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 Footnote, Document Footnote Options, footnote inspection and editing.
use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"footnote.insert",
"Insert Footnote",
["Type"],
None,
"{text?} — a footnote reference at the insertion point; the caret moves into the new footnote's text",
in_story_text,
insert
),
cmd!(
"footnote.options",
"Document Footnote Options…",
["Type"],
None,
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
has_doc,
options
),
cmd!(query "footnote.list", "Footnotes", [], None, "{story?} → [{story, id, index, anchor, text}] in story order", has_doc, list),
cmd!("footnote.setText", "Set Footnote Text", [], None, "{story, id, text} — replaces the footnote's text", has_doc, set_text),
cmd!("footnote.delete", "Delete Footnote", [], None, "{story, id} — deletes the reference (and so the footnote)", has_doc, delete),
cmd!(
noundo "footnote.goToReference",
"Go to Footnote Reference",
["Type"],
None,
"{} — from footnote text, puts the caret after the footnote's reference",
in_note,
go_to_reference
),
]
}
fn in_story_text(s: &Session) -> std::result::Result<(), String> {
super::has_text(s)?;
match s.active().and_then(|d| d.selection.text) {
Some(t) if t.cell.is_some() => Err("footnotes can't be inserted in table cells or other footnotes".into()),
_ => Ok(()),
}
}
fn in_note(s: &Session) -> std::result::Result<(), String> {
super::has_text(s)?;
match s.active().and_then(|d| d.selection.text) {
Some(t) if t.cell.is_some_and(|c| c.footnote_id().is_some()) => Ok(()),
_ => Err("the insertion point is not in footnote text".into()),
}
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?;
let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() };
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
let r = r.start.min(st.len())..r.end.min(st.len());
st.delete(r.clone());
let id = st.insert_note(r.start, &text, para);
sel.text = Some(TextSel { story: t.story, anchor: text.len(), focus: text.len(), frame: t.frame, cell: Some(CellAddr::footnote(id)) });
Ok(json!({"story": t.story.0, "id": id}))
})
}
/// Overlay `p` onto `base` (objects merge key by key).
fn merge(base: &mut Value, p: &Value) {
match (base, p) {
(Value::Object(b), Value::Object(o)) => {
for (k, v) in o {
match b.get_mut(k) {
Some(slot) if slot.is_object() && v.is_object() => merge(slot, v),
_ => {
b.insert(k.clone(), v.clone());
}
}
}
}
(b, v) => *b = v.clone(),
}
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.footnote_options.clone();
if p.as_object().is_none_or(|o| o.is_empty()) {
return Ok(serde_json::to_value(&cur).unwrap_or_default());
}
let mut v = serde_json::to_value(&cur).unwrap_or_default();
merge(&mut v, p);
let next: FootnoteOptions = serde_json::from_value(v).map_err(|e| bad("footnote.options", e.to_string()))?;
let out = serde_json::to_value(&next).unwrap_or_default();
if next != cur {
s.edit(|d, _| {
d.footnote_options = next;
Ok(Value::Null)
})?;
}
Ok(out)
}
fn story_id(p: &Value, cmd: &str) -> Result<StoryId> {
p.get("story").and_then(Value::as_u64).map(StoryId).ok_or_else(|| bad(cmd, "missing story"))
}
fn note_id(p: &Value, cmd: &str) -> Result<u64> {
p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing id"))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let only = p.get("story").and_then(Value::as_u64).map(StoryId);
let mut out = Vec::new();
for st in d.stories.values().filter(|st| only.is_none_or(|o| o == st.id)) {
for (k, n) in st.notes.iter().enumerate() {
out.push(json!({"story": st.id.0, "id": n.id, "index": k, "anchor": st.note_anchor(n.id), "text": n.text.text}));
}
}
Ok(Value::Array(out))
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let sid = story_id(p, "footnote.setText")?;
let id = note_id(p, "footnote.setText")?;
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?;
let len = n.text.len();
n.text.replace(0..len, &text);
Ok(Value::Null)
})
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let sid = story_id(p, "footnote.delete")?;
let id = note_id(p, "footnote.delete")?;
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let at = st.note_anchor(id).ok_or_else(|| bad("footnote.delete", format!("no footnote {id}")))?;
st.delete(at..at + designcraft_doc::FOOTNOTE_REF.len_utf8());
if sel.text.is_some_and(|t| t.story == sid && t.cell == Some(CellAddr::footnote(id))) {
sel.text = Some(TextSel::caret(sid, at));
}
Ok(Value::Null)
})
}
fn go_to_reference(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let t = st.selection.text.ok_or_else(|| bad("footnote.goToReference", "no caret"))?;
let id = t.cell.and_then(|c| c.footnote_id()).ok_or_else(|| bad("footnote.goToReference", "not in footnote text"))?;
let at = st.doc.story(t.story).and_then(|x| x.note_anchor(id)).ok_or_else(|| bad("footnote.goToReference", "footnote not found"))?;
let pos = at + designcraft_doc::FOOTNOTE_REF.len_utf8();
st.selection.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t });
st.bump_revision();
Ok(json!({"story": t.story.0, "pos": pos}))
}
#[cfg(test)]
mod tests {
use super::*;
fn session_with_text() -> (Session, StoryId) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "Body text with a note here."})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
(s, sid)
}
#[test]
fn insert_type_and_undo_footnotes() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap();
let r = s.execute("footnote.insert", &json!({})).unwrap();
let id = r["id"].as_u64().unwrap();
// Typing goes into the footnote.
s.execute("text.insert", &json!({"text": "A source."})).unwrap();
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!(st.notes.len(), 1);
assert_eq!(st.notes[0].text.text, "A source.");
assert_eq!(st.text.matches(designcraft_doc::FOOTNOTE_REF).count(), 1);
// The footnote is laid out in the frame.
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
assert_eq!(cs.frames[0].notes.len(), 1);
// Back to the reference.
let r = s.execute("footnote.goToReference", &json!({})).unwrap();
assert_eq!(r["pos"], json!(16 + designcraft_doc::FOOTNOTE_REF.len_utf8()));
// Listing, editing, options.
let l = s.execute("footnote.list", &json!({})).unwrap();
assert_eq!(l[0]["text"], "A source.");
s.execute("footnote.setText", &json!({"story": sid.0, "id": id, "text": "Changed."})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().notes[0].text.text, "Changed.");
let o = s.execute("footnote.options", &json!({"style": "lowerRoman", "rule": {"width": 100.0}})).unwrap();
assert_eq!(o["style"], "lowerRoman");
assert_eq!(o["rule"]["width"], 100.0);
assert_eq!(o["rule"]["on"], true);
assert!(s.execute("footnote.options", &json!({"restart": "bogus"})).is_err());
// Deleting removes the reference; undo brings everything back.
s.execute("footnote.delete", &json!({"story": sid.0, "id": id})).unwrap();
assert!(s.doc().unwrap().doc.story(sid).unwrap().notes.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().notes.len(), 1);
}
#[test]
fn not_in_cells() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("footnote.insert", &json!({"text": "x"})).unwrap();
// Caret is now in footnote text: no nested footnotes.
assert!(s.execute("footnote.insert", &json!({})).is_err());
}
}