mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 10:46:50 -06:00
Add multi-click text selection, pending caret formatting and clipboard support, text-aware colour controls, guide/grid snapping, and frame threading with loaded-port interactions. Include the layout, drop-cap and IDML updates carried from #83, with safeguards for generated and linked stories, external text references, local IDs, locked frames and atomic colour edits. Fixes #23. Co-authored-by: Sean Hammond <sean.hammond@und.edu> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
328 lines
12 KiB
Rust
328 lines
12 KiB
Rust
//! Footnotes.
|
||
//!
|
||
//! A footnote reference is a [`FOOTNOTE_REF`] character in a story; the story's
|
||
//! [`Story::notes`] holds one [`Footnote`] per reference, in text order (the k-th reference
|
||
//! character owns `notes[k]`). Deleting the reference deletes the footnote; inserting text that
|
||
//! contains reference characters inserts empty footnotes. Each footnote's text is a frameless
|
||
//! [`Story`] (like a table cell) composed at the bottom of the column its reference lands in.
|
||
//!
|
||
//! Numbering and layout come from the document-wide [`FootnoteOptions`]
|
||
//! (Type › Document Footnote Options).
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::attrs::{NumberStyle, Position};
|
||
use crate::ids::StoryId;
|
||
use crate::item::FirstBaseline;
|
||
use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat, Story};
|
||
|
||
/// Footnote reference character (the number shown in the text).
|
||
pub const FOOTNOTE_REF: char = '\u{E00A}';
|
||
|
||
/// Footnote addresses reuse [`crate::CellAddr`] with this pseudo table id (the row is the
|
||
/// footnote id): text selections, typing and formatting reach footnote text like cell text.
|
||
pub const FOOTNOTE_TABLE: u64 = u64::MAX;
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct Footnote {
|
||
/// Stable within the story (selection addresses).
|
||
pub id: u64,
|
||
/// The footnote text (no frames).
|
||
pub text: Story,
|
||
}
|
||
|
||
/// Restart Numbering Every.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum NoteRestart {
|
||
/// Continuous through the document.
|
||
#[default]
|
||
Never,
|
||
Page,
|
||
Spread,
|
||
Section,
|
||
}
|
||
|
||
/// Where the prefix/suffix appear.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum AffixIn {
|
||
#[default]
|
||
None,
|
||
Reference,
|
||
Text,
|
||
Both,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase", default)]
|
||
pub struct NoteRule {
|
||
pub on: bool,
|
||
pub weight: f64,
|
||
pub color: String,
|
||
pub tint: f32,
|
||
/// Rule length.
|
||
pub width: f64,
|
||
/// Baseline offset below the top of the footnote area.
|
||
pub offset: f64,
|
||
pub left_indent: f64,
|
||
}
|
||
|
||
impl Default for NoteRule {
|
||
fn default() -> Self {
|
||
NoteRule { on: true, weight: 1.0, color: designcraft_color::swatch::BLACK.into(), tint: 1.0, width: 72.0, offset: 0.0, left_indent: 0.0 }
|
||
}
|
||
}
|
||
|
||
/// Type › Document Footnote Options (Numbering and Formatting, Layout).
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase", default)]
|
||
pub struct FootnoteOptions {
|
||
pub style: NumberStyle,
|
||
pub start_at: u32,
|
||
pub restart: NoteRestart,
|
||
pub prefix: String,
|
||
pub suffix: String,
|
||
pub affix_in: AffixIn,
|
||
/// Footnote reference number in text.
|
||
pub ref_position: Position,
|
||
pub ref_char_style: String,
|
||
/// Footnote text formatting.
|
||
pub para_style: String,
|
||
/// Between the number and the footnote text.
|
||
pub separator: String,
|
||
/// Minimum space between the text and the first footnote.
|
||
pub space_before: f64,
|
||
pub space_between: f64,
|
||
pub first_baseline: FirstBaseline,
|
||
pub first_baseline_min: f64,
|
||
/// Footnotes span all columns of a multi-column frame (else each column gets its own).
|
||
pub span_columns: bool,
|
||
/// Rule above the first footnote in a column.
|
||
pub rule: NoteRule,
|
||
}
|
||
|
||
impl Default for FootnoteOptions {
|
||
fn default() -> Self {
|
||
FootnoteOptions {
|
||
style: NumberStyle::Arabic,
|
||
start_at: 1,
|
||
restart: NoteRestart::Never,
|
||
prefix: String::new(),
|
||
suffix: String::new(),
|
||
affix_in: AffixIn::None,
|
||
ref_position: Position::Superscript,
|
||
ref_char_style: NO_CHAR_STYLE.into(),
|
||
para_style: BASIC_PARAGRAPH.into(),
|
||
separator: "\t".into(),
|
||
space_before: 0.0,
|
||
space_between: 0.0,
|
||
first_baseline: FirstBaseline::Leading,
|
||
first_baseline_min: 0.0,
|
||
span_columns: false,
|
||
rule: NoteRule::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl FootnoteOptions {
|
||
/// The number as shown at the reference (`text == false`) or before the footnote text.
|
||
pub fn label(&self, n: u32, text: bool) -> String {
|
||
let num = self.style.format(n);
|
||
let affix = match self.affix_in {
|
||
AffixIn::None => false,
|
||
AffixIn::Both => true,
|
||
AffixIn::Reference => !text,
|
||
AffixIn::Text => text,
|
||
};
|
||
if affix { format!("{}{num}{}", self.prefix, self.suffix) } else { num }
|
||
}
|
||
}
|
||
|
||
impl Story {
|
||
/// Index (in [`Story::notes`]) of the footnote whose reference is the k-th reference before `pos`.
|
||
pub fn notes_before(&self, pos: usize) -> usize {
|
||
let pos = crate::story::floor_char_boundary(&self.text, pos);
|
||
self.text[..pos].matches(FOOTNOTE_REF).count()
|
||
}
|
||
|
||
pub fn note(&self, id: u64) -> Option<&Footnote> {
|
||
self.notes.iter().find(|n| n.id == id).map(|n| &**n)
|
||
}
|
||
|
||
/// Mutable footnote (copy-on-write); bumps the story revision.
|
||
pub fn note_mut(&mut self, id: u64) -> Option<&mut Footnote> {
|
||
let n = self.notes.iter_mut().find(|n| n.id == id)?;
|
||
self.rev += 1;
|
||
Some(Arc::make_mut(n))
|
||
}
|
||
|
||
/// Byte offset of a footnote's reference character.
|
||
pub fn note_anchor(&self, id: u64) -> Option<usize> {
|
||
let k = self.notes.iter().position(|n| n.id == id)?;
|
||
self.text.match_indices(FOOTNOTE_REF).nth(k).map(|(i, _)| i)
|
||
}
|
||
|
||
/// Insert a footnote reference at `pos` with the given footnote text. Returns the footnote id.
|
||
pub fn insert_note(&mut self, pos: usize, text: &str, para: ParaFormat) -> u64 {
|
||
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
|
||
let fmt = self.char_format_at(pos).clone();
|
||
self.insert_with(pos, &FOOTNOTE_REF.to_string(), fmt);
|
||
let k = self.notes_before(pos);
|
||
let note = Arc::make_mut(&mut self.notes[k]);
|
||
note.text = Story::with_text(StoryId(0), text, para);
|
||
note.id
|
||
}
|
||
|
||
pub(crate) fn next_note_id(&self) -> u64 {
|
||
crate::ids::next_local_id(self.notes.iter().map(|n| n.id))
|
||
}
|
||
|
||
/// Insert empty footnotes for `n` new reference characters before which `k` references sit.
|
||
pub(crate) fn notes_inserted(&mut self, k: usize, n: usize) {
|
||
for i in 0..n {
|
||
let id = self.next_note_id();
|
||
let mut text = Story::new(StoryId(0));
|
||
text.chars[0].format = CharFormat::default();
|
||
self.notes.insert((k + i).min(self.notes.len()), Arc::new(Footnote { id, text }));
|
||
}
|
||
}
|
||
|
||
/// Make the footnote list match the reference characters (after wholesale text changes).
|
||
pub fn fix_notes(&mut self) {
|
||
let n = self.text.matches(FOOTNOTE_REF).count();
|
||
if n < self.notes.len() {
|
||
self.notes.truncate(n);
|
||
} else if n > self.notes.len() {
|
||
let k = self.notes.len();
|
||
self.notes_inserted(k, n - k);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn check_notes(&self) -> Result<(), String> {
|
||
let n = self.text.matches(FOOTNOTE_REF).count();
|
||
if n != self.notes.len() {
|
||
return Err(format!("story {}: {n} footnote references, {} footnotes", self.id.0, self.notes.len()));
|
||
}
|
||
let mut ids = std::collections::HashSet::new();
|
||
for note in &self.notes {
|
||
if !ids.insert(note.id) {
|
||
return Err(format!("story {}: footnote id {} used twice", self.id.0, note.id));
|
||
}
|
||
note.text.check()?;
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl crate::Document {
|
||
/// How many numbers of list `name` stories before `sid` used (page order), so `sid`'s numbering
|
||
/// carries on — 0 for lists that don't continue across stories.
|
||
pub fn list_start(&self, sid: StoryId, name: &str) -> u32 {
|
||
if !self.settings.lists.iter().any(|l| l.name == name && l.continue_across_stories) {
|
||
return 0;
|
||
}
|
||
let key = |st: &Story| -> (usize, u64) {
|
||
let page = st.frames.first().and_then(|f| self.page_of_item(*f)).unwrap_or(usize::MAX);
|
||
(page, st.id.0)
|
||
};
|
||
let Some(me) = self.story(sid) else { return 0 };
|
||
let mine = key(me);
|
||
let mut before: Vec<&Story> = self.stories.values().map(|s| s.as_ref()).filter(|st| st.id != sid && key(st) < mine).collect();
|
||
before.sort_by_key(|st| key(st));
|
||
let mut n = 0u32;
|
||
for st in before {
|
||
for (p, r) in st.paras.iter().zip(st.para_ranges()) {
|
||
let (pp, _) = self.styles.resolve_para(p);
|
||
if pp.list_type == crate::ListType::Numbers && pp.list_name == name && !r.is_empty() {
|
||
n = pp.start_at.map_or(n.saturating_add(1), |s| s.max(1));
|
||
}
|
||
}
|
||
}
|
||
n
|
||
}
|
||
|
||
/// Number of the first footnote of story `sid` when numbering runs through the document:
|
||
/// the start number plus the footnotes of stories that start on earlier pages.
|
||
pub fn footnote_start(&self, sid: StoryId) -> u32 {
|
||
let o = &self.footnote_options;
|
||
if o.restart != NoteRestart::Never {
|
||
return o.start_at;
|
||
}
|
||
let key = |st: &Story| -> (usize, u64) {
|
||
let page = st.frames.first().and_then(|f| self.page_of_item(*f)).unwrap_or(usize::MAX);
|
||
(page, st.id.0)
|
||
};
|
||
let Some(me) = self.story(sid) else { return o.start_at };
|
||
let mine = key(me);
|
||
let before: usize = self.stories.values().filter(|st| st.id != sid && !st.notes.is_empty() && key(st) < mine).map(|st| st.notes.len()).sum();
|
||
o.start_at + before as u32
|
||
}
|
||
|
||
/// Absolute page an item sits on (its center), None on parent pages / pasteboard-only spreads.
|
||
pub fn page_of_item(&self, id: crate::ItemId) -> Option<usize> {
|
||
let loc = self.find(id)?;
|
||
let crate::SpreadRef::Doc(si) = loc.spread else { return None };
|
||
let sp = self.spread(loc.spread)?;
|
||
let c = self.item(id)?.bounds().center();
|
||
Some(self.first_page_of_spread(si) + sp.page_at_x(c.x).unwrap_or(0))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn st(t: &str) -> Story {
|
||
Story::with_text(StoryId(1), t, ParaFormat::default())
|
||
}
|
||
|
||
#[test]
|
||
fn references_own_notes_in_order() {
|
||
let mut s = st("alpha beta gamma");
|
||
let b = s.insert_note(10, "second", ParaFormat::default());
|
||
let a = s.insert_note(5, "first", ParaFormat::default());
|
||
s.check().unwrap();
|
||
assert_eq!(s.notes.len(), 2);
|
||
assert_eq!(s.notes[0].id, a);
|
||
assert_eq!(s.notes[0].text.text, "first");
|
||
assert_eq!(s.notes[1].text.text, "second");
|
||
assert_eq!(s.note_anchor(b), Some(10 + FOOTNOTE_REF.len_utf8()));
|
||
// Deleting the first reference deletes its note only.
|
||
s.delete(5..5 + FOOTNOTE_REF.len_utf8());
|
||
s.check().unwrap();
|
||
assert_eq!(s.notes.len(), 1);
|
||
assert_eq!(s.notes[0].id, b);
|
||
// Typing a reference character (paste) creates an empty note.
|
||
s.insert(0, &FOOTNOTE_REF.to_string());
|
||
s.check().unwrap();
|
||
assert_eq!(s.notes.len(), 2);
|
||
assert_eq!(s.notes[1].id, b);
|
||
assert!(s.notes[0].text.text.is_empty());
|
||
// Deleting a range spanning everything clears all notes.
|
||
let n = s.len();
|
||
s.delete(0..n);
|
||
s.check().unwrap();
|
||
assert!(s.notes.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn labels_and_symbols() {
|
||
let mut o = FootnoteOptions::default();
|
||
assert_eq!(o.label(3, false), "3");
|
||
o.style = NumberStyle::Symbols;
|
||
assert_eq!(o.label(1, false), "*");
|
||
assert_eq!(o.label(2, false), "†");
|
||
assert_eq!(o.label(7, false), "**");
|
||
o.style = NumberStyle::Arabic;
|
||
o.prefix = "[".into();
|
||
o.suffix = "]".into();
|
||
o.affix_in = AffixIn::Reference;
|
||
assert_eq!(o.label(4, false), "[4]");
|
||
assert_eq!(o.label(4, true), "4");
|
||
}
|
||
}
|