mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 19:26:46 -06:00
Found while testing crates/compose/src/notes.rs:133 (also crates/doc/src/notes.rs:265 footnote_start and crates/doc/src/endnotes.rs:150 endnote_start) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
236 lines
10 KiB
Rust
236 lines
10 KiB
Rust
//! 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};
|
||
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|kanji, 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 = super::text_param(p, "text");
|
||
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).ok_or_else(|| bad("footnote.insert", "the story's footnotes don't match their references"))?;
|
||
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 = super::text_param(p, "text");
|
||
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());
|
||
}
|
||
|
||
#[test]
|
||
fn huge_start_at_does_not_overflow() {
|
||
let (mut s, sid) = session_with_text();
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap();
|
||
s.execute("footnote.insert", &json!({"text": "x"})).unwrap();
|
||
s.execute("footnote.options", &json!({"startAt": 4294967295u32})).unwrap();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let cs = s.cache.get(&d, sid, None);
|
||
assert_eq!(cs.frames[0].notes.len(), 1);
|
||
}
|
||
}
|