fix: footnote counting per page/chapter and collecting at the end really work

Both were selectable in the dialog and changed nothing — they only rode the
file formats, so an imported document numbered and placed its notes wrong.
noteLabels now counts within a bucket (class, chapter, or the page pageBreaks
publishes through setNoteAnchorPages); a collected footnote leaves position:
absolute and opens its own page, as LibreOffice does. Counting and position
move to the footnote tab, the only place LibreOffice offers them.

The separator's own alignment moves the line alone — probed, notes stay left.
This commit is contained in:
Steffen Becker committed 2026-08-14 23:11:10 +02:00
1 parent ba827b4ce9
commit bf564fbab4
7 files changed
+116 -32

No files matched your search

+19 -6
View File
@@ -47,16 +47,27 @@ note it was copied from; an anchor that ends up *inside* a note is dropped, and
one there is refused (LibreOffice and Word refuse it too). A settings change is no
document change, so the dialog's path carries the `RESYNC_NOTES` meta.
**Restarting the count.** `noteLabels` counts within a bucket: the class alone (document-wide),
its chapter (`collectNoteRefs` counts the level-1 headings above each anchor), or its page —
footnotes only, as in LibreOffice, since the endnote list has no page of its own. The page is
the one thing numbering cannot see, so `pageBreaks.ts` publishes its `anchorPages` through
`setNoteAnchorPages` and fires `RESYNC_NOTES` when the map moves; the renumber may rewrap, so
it costs one more pass off the same `MAX_CONVERGE_PASSES` budget. The dialog shows counting
and position on the footnote tab only, which is where LibreOffice keeps them.
## Placement (`pageBreaks.ts`)
A footnote is out of the flow (`position: absolute`, `left`/`right` = the page margins)
and `pageBreaks.ts` gives it its `top`. Endnotes stay in flow and the first one carries
`forceBreakBefore`, because LibreOffice starts the endnote list on a new page (probed).
and `pageBreaks.ts` gives it its `top`. A **collected** note stays in flow and the first one
carries `forceBreakBefore`, because LibreOffice opens a new page for the list (probed).
Which of the two a footnote is comes from `position: 'document'` → the `data-collect-footnotes`
attribute (`applyNoteVars`) → `editor.css`, and pagination reads it back off the note's own
computed `position` rather than the setting: the box is the truth either way.
The reservation runs **inside** one pagination pass — reading it back across passes would
break the file's own rule that a pass must not read its own last answer:
1. `collectLeaves` skips footnote notes and walks the section for the endnotes.
1. `collectLeaves` skips the notes that are out of flow and walks the section for the rest.
2. Each footnote's height is measured once: out of flow at a fixed width, it does not
depend on the page it lands on.
3. `placeLeaves(reserved)` takes a per-page reservation off each `contentEnd`. A bounded
@@ -69,9 +80,11 @@ break the file's own rule that a pass must not read its own last answer:
frozen layout leaves every footnote on the page it used to belong to, without the
position an edit above the section leaves the decorations on a stale one.
**Deliberate ceiling:** a note is never split across pages. A page whose notes outgrow its
content area keeps one line of body text and lets them overflow. Splitting the note, as
LibreOffice does, is the upgrade path (`ponytail:` comment at the cap).
**Deliberate ceilings:** a note is never split across pages — a page whose notes outgrow its
content area keeps one line of body text and lets them overflow, and splitting the note as
LibreOffice does is the upgrade path. Collected notes take **one** page in anchor order;
LibreOffice gives each class a page of its own, which needs a section order that is not the
anchor order the exports read. Both carry a `ponytail:` comment at the cap.
## LibreOffice's defaults, as probed
+10 -8
View File
@@ -76,15 +76,17 @@
<input type="number" min="1" max="9999" value={cls.startAt}
onchange={(e) => { const v = num(e); if (v >= 1) patch({ startAt: Math.round(v) }); }} />
</label>
<label class="row">
<span>{t().notesDialog.restart}</span>
<select value={cls.restart} onchange={(e) => patch({ restart: e.currentTarget.value as NoteRestart })}>
{#each RESTARTS as r}
<option value={r}>{r === 'page' ? t().notesDialog.restartPage : r === 'chapter' ? t().notesDialog.restartChapter : t().notesDialog.restartDocument}</option>
{/each}
</select>
</label>
<!-- Counting and position are footnote-only, as they are in LibreOffice: the
endnote list has no page of its own to count on, and no place to move to. -->
{#if kind === 'footnote'}
<label class="row">
<span>{t().notesDialog.restart}</span>
<select value={cls.restart} onchange={(e) => patch({ restart: e.currentTarget.value as NoteRestart })}>
{#each RESTARTS as r}
<option value={r}>{r === 'page' ? t().notesDialog.restartPage : r === 'chapter' ? t().notesDialog.restartChapter : t().notesDialog.restartDocument}</option>
{/each}
</select>
</label>
<label class="row">
<span>{t().notesDialog.position}</span>
<select value={cls.position} onchange={(e) => patch({ position: e.currentTarget.value === 'document' ? 'document' : 'page' })}>
+33 -8
View File
@@ -148,21 +148,25 @@ export const NoteSection = Node.create({
},
});
export type NoteRefInfo = { id: string; kind: NoteKind; pos: number };
// `chapter` is the running count of top-level level-1 headings above the anchor, which
// is what a per-chapter restart counts within.
export type NoteRefInfo = { id: string; kind: NoteKind; pos: number; chapter: number };
// Every anchor in the body, in document order. The note section is skipped: a note's
// own text may hold an anchor, and that one numbers nothing.
export function collectNoteRefs(doc: PMNode): NoteRefInfo[] {
const out: NoteRefInfo[] = [];
let chapter = 0;
doc.forEach((child, offset) => {
if (child.type.name === 'noteSection') return;
if (child.type.name === 'heading' && child.attrs.level === 1) chapter += 1;
child.descendants((node, pos) => {
if (node.type.name === 'noteRef') {
out.push({ id: String(node.attrs.id ?? ''), kind: readKind(node.attrs.kind), pos: offset + 1 + pos });
out.push({ id: String(node.attrs.id ?? ''), kind: readKind(node.attrs.kind), pos: offset + 1 + pos, chapter });
}
});
if (child.type.name === 'noteRef') {
out.push({ id: String(child.attrs.id ?? ''), kind: readKind(child.attrs.kind), pos: offset });
out.push({ id: String(child.attrs.id ?? ''), kind: readKind(child.attrs.kind), pos: offset, chapter });
}
});
return out;
@@ -203,14 +207,35 @@ function noteEntryPos(doc: PMNode, id: string): number | null {
return found;
}
// The page each footnote anchor landed on, published by pageBreaks.ts after every
// pagination pass — the only place that knows it. Returns whether it moved, which is
// what makes the plugin renumber.
let anchorPages: ReadonlyMap<string, number> = new Map();
export function setNoteAnchorPages(next: ReadonlyMap<string, number>): boolean {
const same = next.size === anchorPages.size
&& Array.from(next).every(([id, page]) => anchorPages.get(id) === page);
anchorPages = next;
return !same;
}
// The label each anchor shows, keyed by note id: footnotes and endnotes count
// separately, each from its own start value.
export function noteLabels(refs: NoteRefInfo[], settings: NoteSettings): Map<string, string> {
const seen: Record<NoteKind, number> = { footnote: 0, endnote: 0 };
// separately, each from its own start value. A restart counts within the anchor's page
// or chapter instead — per page is a footnote-only option, as it is in LibreOffice,
// since the endnote list has no page of its own to count on.
export function noteLabels(
refs: NoteRefInfo[], settings: NoteSettings, pages: ReadonlyMap<string, number> = anchorPages,
): Map<string, string> {
const seen = new Map<string, number>();
const out = new Map<string, string>();
for (const ref of refs) {
out.set(ref.id, noteLabel(seen[ref.kind], ref.kind, settings));
seen[ref.kind] += 1;
const restart = settings[ref.kind].restart;
const bucket = restart === 'chapter' ? `${ref.kind}:c${ref.chapter}`
: restart === 'page' && ref.kind === 'footnote' ? `${ref.kind}:p${pages.get(ref.id) ?? 0}`
: ref.kind;
const index = seen.get(bucket) ?? 0;
out.set(ref.id, noteLabel(index, ref.kind, settings));
seen.set(bucket, index + 1);
}
return out;
}
+24 -9
View File
@@ -2,6 +2,7 @@ import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import { COLUMNS_FIT_MARGIN_PX } from './columns';
import { RESYNC_NOTES, setNoteAnchorPages } from './notes';
export type PageBreakDebugSnapshot = {
timestamp: string;
@@ -760,8 +761,8 @@ export const PageBreaks = Extension.create({
// so far: added back, so what is measured is the document, not the last pass's
// answer to it. Reading the answer back is what made the layout flip-flop.
let cumulativeDropped = 0;
// Only the first endnote opens a page; the rest follow it down the column.
let seenEndnote = false;
// Only the first collected note opens a page; the rest follow it down.
let seenCollected = false;
// A leaf's border-box top within .tiptap, in document px. Summing offsetTop up
// the offsetParent chain is unaffected by .paper's transform:scale, so it's the
@@ -914,23 +915,28 @@ export const PageBreaks = Extension.create({
walkTableRows(child, inTableCell);
continue;
}
// The note section (notes.ts) is not body text: a footnote is out of the
// flow and placed at the foot of its anchor's page below, an endnote flows
// here and starts its own page, as LibreOffice collects them.
// The note section (notes.ts) is not body text. A note at the foot of its
// page is out of the flow and placed below; a collected one flows here and
// starts its own page, as LibreOffice collects both classes.
if (child.classList.contains('note-section')) {
walk(child, inTableCell);
continue;
}
if (child.classList.contains('note')) {
if (child.dataset?.noteKind === 'footnote') continue;
// The setting reaches this through editor.css, which takes a collected
// footnote out of `position: absolute` — the note's own box is the truth.
if (getComputedStyle(child).position === 'absolute') continue;
// ponytail: one page for the collected notes, in anchor order. LibreOffice
// groups the classes onto a page each, which needs a section order that
// isn't the anchor order the exports read.
leaves.push({
el: child,
kind: 'splittable',
naturalTop: naturalTopOf(child),
naturalHeight: child.offsetHeight,
forceBreakBefore: !seenEndnote,
forceBreakBefore: !seenCollected,
});
seenEndnote = true;
seenCollected = true;
continue;
}
// The generated table of contents (tableOfContents.ts) is a block atom with
@@ -1579,6 +1585,7 @@ export const PageBreaks = Extension.create({
// and the fixed point below only has to settle *which* page that is.
const footnotes: FootnoteBox[] = Array
.from(dom.querySelectorAll<HTMLElement>('.note[data-note-kind="footnote"]'))
.filter((el) => getComputedStyle(el).position === 'absolute')
.map((el) => ({ id: el.dataset.noteId ?? '', el, height: el.offsetHeight }));
// The separator's own band. The three lengths are registered (@property in
// editor.css), so these read back in px rather than as "0.1cm".
@@ -1776,10 +1783,18 @@ export const PageBreaks = Extension.create({
isUpdating = false;
// A per-page restart counts within the page each anchor landed on, which only
// this pass knows (notes.ts). Renumbering can rewrap, so it takes a pass of its
// own — on the same budget, since a label that moves its anchor never settles.
const notePagesMoved = setNoteAnchorPages(placed.anchorPages);
if (notePagesMoved && convergePasses < MAX_CONVERGE_PASSES) {
editorView.dispatch(editorView.state.tr.setMeta(RESYNC_NOTES, true).setMeta('addToHistory', false));
}
// Each pass measures against the spacers present at its start, so a changed
// result needs another pass to re-measure and settle (e.g. after an orientation
// switch). Bounded by MAX_CONVERGE_PASSES against a two-layout ping-pong.
if (placementsChanged && convergePasses < MAX_CONVERGE_PASSES) {
if ((placementsChanged || notePagesMoved) && convergePasses < MAX_CONVERGE_PASSES) {
convergePasses++;
schedule();
}
+4
View File
@@ -145,4 +145,8 @@ export function applyNoteVars(s: NoteSettings): void {
root.setProperty('--note-sep-rel-width', `${s.separator.relWidthPercent}%`);
root.setProperty('--note-sep-color', s.separator.color);
root.setProperty('--note-sep-align', s.separator.align);
// "Collect at end of document": the footnotes leave the page foot and flow with the
// endnote list. editor.css takes them out of `position: absolute` from this, which is
// also how pageBreaks.ts reads the setting.
document.documentElement.toggleAttribute('data-collect-footnotes', s.footnote.position === 'document');
}
+5
View File
@@ -278,6 +278,11 @@
left: var(--page-margin-left);
right: var(--page-margin-right);
}
/* Collected at the end of the document instead (noteSettings.ts): the footnotes rejoin
the flow and open their own page, where the endnote list also starts. */
:root[data-collect-footnotes] .paper .tiptap .note[data-note-kind='footnote'] {
position: static;
}
/* The separator hangs above the first note of a page, inside the band pageBreaks.ts
already reserved for it — so it costs the note's own box no height, which is what
keeps that height out of the reservation that produced it. A gradient, not a
+21 -1
View File
@@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest';
import { noteLabel, noteLabels, type NoteRefInfo } from '../../src/lib/editor/extensions/notes';
import { clampNoteSettings, DEFAULT_NOTE_SETTINGS, type NoteSettings } from '../../src/lib/storage/noteSettings';
const REF = (id: string, kind: 'footnote' | 'endnote'): NoteRefInfo => ({ id, kind, pos: 0 });
const REF = (id: string, kind: 'footnote' | 'endnote', chapter = 0): NoteRefInfo =>
({ id, kind, pos: 0, chapter });
const withFootnote = (over: Partial<NoteSettings['footnote']>): NoteSettings =>
({ ...DEFAULT_NOTE_SETTINGS, footnote: { ...DEFAULT_NOTE_SETTINGS.footnote, ...over } });
@@ -38,6 +39,25 @@ describe('note numbering', () => {
const labels = noteLabels(refs, DEFAULT_NOTE_SETTINGS);
expect([...labels.values()]).toEqual(['1', 'i', '2', 'ii']);
});
// LibreOffice, probed: the count starts over on each page / at each chapter.
it('restarts per page, from the page each anchor landed on', () => {
const refs = [REF('a', 'footnote'), REF('b', 'footnote'), REF('c', 'footnote')];
const pages = new Map([['a', 1], ['b', 2], ['c', 2]]);
const labels = noteLabels(refs, withFootnote({ restart: 'page' }), pages);
expect([...labels.values()]).toEqual(['1', '1', '2']);
});
it('leaves an endnote document-wide: LibreOffice offers no per-page count for one', () => {
const refs = [REF('a', 'endnote'), REF('b', 'endnote')];
const s: NoteSettings = { ...DEFAULT_NOTE_SETTINGS, endnote: { ...DEFAULT_NOTE_SETTINGS.endnote, restart: 'page' } };
expect([...noteLabels(refs, s, new Map()).values()]).toEqual(['i', 'ii']);
});
it('restarts per chapter, off the anchor’s own chapter', () => {
const refs = [REF('a', 'footnote', 1), REF('b', 'footnote', 1), REF('c', 'footnote', 2)];
expect([...noteLabels(refs, withFootnote({ restart: 'chapter' })).values()]).toEqual(['1', '2', '1']);
});
});
describe('note settings', () => {