M6.1: footnotes at page bottom, endnotes at document end, click-to-edit notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d1a0ed39c0
commit
5a56dda633
|
|
@ -179,8 +179,9 @@ fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult {
|
|||
para.insert_text(len, &format!(" {text}"), &CharProps::default())?;
|
||||
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(&at, InlineObject::NoteRef { kind, id, custom: String::new() }, &CharProps { style: Some(refstyle.into()), ..Default::default() })?;
|
||||
s.sel = Selection::caret(end);
|
||||
s.doc.insert_object(&at, InlineObject::NoteRef { kind, id, custom: String::new() }, &CharProps { style: Some(refstyle.into()), ..Default::default() })?;
|
||||
// Like Word, the caret moves into the new note.
|
||||
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,20 @@ impl DocLayout {
|
|||
Some(Pos { story: best.story, path: best.path.clone(), off })
|
||||
}
|
||||
|
||||
/// The story whose text is under a point (body, footnotes, endnotes; not headers/footers).
|
||||
pub fn story_at(&self, page: usize, x: f32, y: f32) -> Option<StoryRef> {
|
||||
let p = self.pages.get(page)?;
|
||||
p.items.iter().find_map(|it| match it {
|
||||
Placed::Lines { story, para, l0, l1, x: lx, y: ly, .. } => {
|
||||
let first = para.lines.get(*l0)?;
|
||||
let last = para.lines.get(l1.checked_sub(1)?)?;
|
||||
let bottom = ly + last.top + last.height - first.top;
|
||||
(y >= *ly - 2.0 && y <= bottom + 2.0 && x >= *lx - 40.0 && x <= lx + last.right + 40.0).then_some(*story)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Which header/footer (if any) is at (x, y) on a page — for double-click editing.
|
||||
pub fn header_footer_at(&self, page: usize, y: f32) -> Option<(StoryRef, bool)> {
|
||||
let p = self.pages.get(page)?;
|
||||
|
|
|
|||
|
|
@ -330,10 +330,42 @@ struct PageBuilder<'a> {
|
|||
bottom: f32,
|
||||
number: u32,
|
||||
web: bool,
|
||||
/// Footnotes waiting for the bottom of the current page: (part id, items, height).
|
||||
notes: Vec<(u32, Vec<Placed>, f32)>,
|
||||
/// Page bottom before footnotes took space.
|
||||
orig_bottom: f32,
|
||||
}
|
||||
|
||||
/// Gap above the footnote separator and its length.
|
||||
const NOTE_SEP: f32 = 12.0;
|
||||
|
||||
impl PageBuilder<'_> {
|
||||
/// Space footnotes take at the bottom of the page.
|
||||
fn notes_h(&self) -> f32 {
|
||||
if self.notes.is_empty() { 0.0 } else { NOTE_SEP + self.notes.iter().map(|n| n.2).sum::<f32>() }
|
||||
}
|
||||
/// Place the collected footnotes at the bottom of the current page.
|
||||
fn flush_notes(&mut self) {
|
||||
if self.notes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let total = self.notes_h();
|
||||
let x = self.sect.margin_left + self.sect.gutter;
|
||||
let mut y = self.orig_bottom - total + NOTE_SEP;
|
||||
let notes = std::mem::take(&mut self.notes);
|
||||
if let Some(pg) = self.pages.last_mut() {
|
||||
pg.items.push(Placed::Rule { x0: x, y0: y - NOTE_SEP / 2.0, x1: x + 144.0, y1: y - NOTE_SEP / 2.0, border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 } });
|
||||
for (_, items, h) in notes {
|
||||
for mut it in items {
|
||||
it.translate(x, y);
|
||||
pg.items.push(it);
|
||||
}
|
||||
y += h;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn new_page(&mut self, first_block: usize, body_top: f32) {
|
||||
self.flush_notes();
|
||||
let s = self.sect;
|
||||
self.number += 1;
|
||||
let (w, h) = if self.web { (s.page_w, f32::MAX / 4.0) } else { (s.page_w, s.page_h) };
|
||||
|
|
@ -343,6 +375,7 @@ impl PageBuilder<'_> {
|
|||
self.cols = s.column_boxes();
|
||||
self.top = body_top;
|
||||
self.bottom = if self.web { f32::MAX / 8.0 } else { s.page_h - s.margin_bottom };
|
||||
self.orig_bottom = self.bottom;
|
||||
self.y = self.top;
|
||||
}
|
||||
fn col_x(&self) -> f32 {
|
||||
|
|
@ -420,7 +453,7 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
|
|||
} else {
|
||||
first_sect
|
||||
};
|
||||
let mut pb = PageBuilder { pages: Vec::new(), sect: sect_ref, sect_idx: 0, col: 0, cols: Vec::new(), y: 0.0, top: 0.0, bottom: 0.0, number: 0, web };
|
||||
let mut pb = PageBuilder { pages: Vec::new(), sect: sect_ref, sect_idx: 0, col: 0, cols: Vec::new(), y: 0.0, top: 0.0, bottom: 0.0, number: 0, web, notes: Vec::new(), orig_bottom: 0.0 };
|
||||
let mut block = 0usize;
|
||||
for (si, (end, sect)) in sections.iter().enumerate() {
|
||||
let sect: &SectionProps = if web { sect_ref } else { sect };
|
||||
|
|
@ -461,6 +494,37 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
|
|||
if pb.pages.is_empty() {
|
||||
pb.new_page(0, sect_ref.margin_top);
|
||||
}
|
||||
// Endnotes after the last paragraph.
|
||||
let endnotes: Vec<u32> = {
|
||||
let mut ids: Vec<(u32, u32)> = ctx.fields.notes.iter().filter(|(id, _)| doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::Endnote)).map(|(a, b)| (*b, *a)).collect();
|
||||
ids.sort();
|
||||
ids.into_iter().map(|(_, id)| id).collect()
|
||||
};
|
||||
if !endnotes.is_empty() && !web {
|
||||
pb.y += 12.0;
|
||||
let (x, ry) = (pb.col_x(), pb.y);
|
||||
if let Some(pg) = pb.page() {
|
||||
pg.items.push(Placed::Rule { x0: x, y0: ry, x1: x + 144.0, y1: ry, border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 } });
|
||||
}
|
||||
pb.y += 8.0;
|
||||
for id in endnotes {
|
||||
let Some(part) = doc.parts.get(&id) else { continue };
|
||||
let blocks = part.blocks.clone();
|
||||
let (items, h) = layout_box(&mut ctx, StoryRef::Part(id), &blocks, &[], pb.col_w(), None, 0);
|
||||
if pb.y + h > pb.bottom && !pb.at_top() {
|
||||
pb.advance(block, pb.top);
|
||||
}
|
||||
let (x, y) = (pb.col_x(), pb.y);
|
||||
if let Some(pg) = pb.page() {
|
||||
for mut it in items {
|
||||
it.translate(x, y);
|
||||
pg.items.push(it);
|
||||
}
|
||||
}
|
||||
pb.y += h;
|
||||
}
|
||||
}
|
||||
pb.flush_notes();
|
||||
let mut pages = pb.pages;
|
||||
if web && let Some(p) = pages.first_mut() {
|
||||
let bottom = p.items.iter().filter_map(|it| if let Placed::Lines { y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None }).fold(0.0f32, f32::max);
|
||||
|
|
@ -524,15 +588,32 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
let Some(first) = pl.lines.get(l0) else { break };
|
||||
let mut l1 = l0;
|
||||
let mut forced_break = None;
|
||||
let mut new_notes: Vec<(u32, Vec<Placed>, f32)> = Vec::new();
|
||||
while l1 < n {
|
||||
let Some(l) = pl.lines.get(l1) else { break };
|
||||
// Footnotes referenced on this line go to the bottom of this page.
|
||||
let mut line_notes = Vec::new();
|
||||
if !pb.web {
|
||||
for (ci, id) in &pl.notes {
|
||||
if *ci >= l.c0 && *ci < l.c1 && !pb.notes.iter().chain(new_notes.iter()).any(|x| x.0 == *id) {
|
||||
if let Some(part) = ctx.doc.parts.get(id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote) {
|
||||
let blocks = part.blocks.clone();
|
||||
let (items, h) = layout_box(ctx, StoryRef::Part(*id), &blocks, &[], pb.sect.text_width(), None, 0);
|
||||
line_notes.push((*id, items, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let extra: f32 = line_notes.iter().map(|n| n.2).sum::<f32>() + if pb.notes.is_empty() && new_notes.is_empty() && !line_notes.is_empty() { NOTE_SEP } else { 0.0 };
|
||||
let limit = pb.bottom - new_notes.iter().map(|n| n.2).sum::<f32>() - if pb.notes.is_empty() && !new_notes.is_empty() { NOTE_SEP } else { 0.0 } - extra;
|
||||
let bottom = pb.y + (l.top + l.height - first.top);
|
||||
if bottom > pb.bottom + 0.01 && l1 > l0 {
|
||||
if bottom > limit + 0.01 && l1 > l0 {
|
||||
break;
|
||||
}
|
||||
if bottom > pb.bottom + 0.01 && l1 == l0 && !pb.at_top() {
|
||||
if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() {
|
||||
break;
|
||||
}
|
||||
new_notes.extend(line_notes);
|
||||
l1 += 1;
|
||||
if matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) && !pb.web {
|
||||
forced_break = Some(l.end);
|
||||
|
|
@ -555,6 +636,12 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
continue;
|
||||
}
|
||||
}
|
||||
// Keep only the notes of the lines that are placed.
|
||||
let placed_end = pl.lines.get(l1.saturating_sub(1)).map(|l| l.c1).unwrap_or(0);
|
||||
new_notes.retain(|(id, ..)| pl.notes.iter().any(|(ci, nid)| nid == id && *ci < placed_end));
|
||||
let before = pb.notes_h();
|
||||
pb.notes.extend(new_notes);
|
||||
pb.bottom -= pb.notes_h() - before;
|
||||
let x = pb.col_x();
|
||||
let y = pb.y;
|
||||
let mut items = Vec::new();
|
||||
|
|
|
|||
|
|
@ -235,3 +235,21 @@ fn empty_center_tab_then_right_tab() {
|
|||
let end = l.caret(&Pos::body(0, 11)).unwrap().x;
|
||||
assert!((end - (72.0 + 468.0)).abs() < 1.0, "{end}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footnotes_sit_at_page_bottom() {
|
||||
let mut d = Document::from_text(&"Body text line.\n".repeat(30));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text("The note text.", Default::default()).styled("FootnoteText");
|
||||
note.props.space_after = Some(0.0);
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
d.insert_object(&Pos::body(3, 4), InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() }, &Default::default()).unwrap();
|
||||
let eid = d.add_part(wordcraft_doc::PartKind::Endnote, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("An endnote.", Default::default()))]);
|
||||
d.insert_object(&Pos::body(5, 2), InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id: eid, custom: String::new() }, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let c = l.caret_on(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, 0).unwrap();
|
||||
assert_eq!(c.page, 0);
|
||||
assert!(c.top > 650.0 && c.top < 720.0, "{c:?}");
|
||||
let body_bottom = l.pages[0].items.iter().filter_map(|it| if let Placed::Lines { story: StoryRef::Body, y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None }).fold(0.0f32, f32::max);
|
||||
assert!(body_bottom < c.top, "{body_bottom} {}", c.top);
|
||||
assert!(l.caret_on(&Pos { story: StoryRef::Part(eid), path: Path::top(0), off: 0 }, 0).is_some());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,6 +390,12 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
}
|
||||
let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer();
|
||||
if pressed {
|
||||
// Clicking into a footnote/endnote edits it; clicking the body from a note goes back.
|
||||
let story = match layout.story_at(page, x, y) {
|
||||
Some(StoryRef::Part(id)) if app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote)) => StoryRef::Part(id),
|
||||
Some(StoryRef::Body) if matches!(story, StoryRef::Part(id) if app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote))) => StoryRef::Body,
|
||||
_ => story,
|
||||
};
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return };
|
||||
// Ctrl/⌘+click follows a hyperlink.
|
||||
if mods.command
|
||||
|
|
|
|||
Loading…
Reference in New Issue