diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index c65158a..3fddd5d 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -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})) } diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 26fc344..df08515 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -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 { + 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)?; diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 87d2aff..c968f60 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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, 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::() } + } + /// 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 = { + 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, 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::() + 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::() - 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(); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 6894a01..15fe32c 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5f8672d..949943f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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