diff --git a/docs/architecture/notes.md b/docs/architecture/notes.md
index 8768c1e..27975a8 100644
--- a/docs/architecture/notes.md
+++ b/docs/architecture/notes.md
@@ -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
diff --git a/src/lib/components/NoteOptionsDialog.svelte b/src/lib/components/NoteOptionsDialog.svelte
index 39e73ad..2f0df65 100644
--- a/src/lib/components/NoteOptionsDialog.svelte
+++ b/src/lib/components/NoteOptionsDialog.svelte
@@ -76,15 +76,17 @@
{ const v = num(e); if (v >= 1) patch({ startAt: Math.round(v) }); }} />
-
- {t().notesDialog.restart}
- patch({ restart: e.currentTarget.value as NoteRestart })}>
- {#each RESTARTS as r}
- {r === 'page' ? t().notesDialog.restartPage : r === 'chapter' ? t().notesDialog.restartChapter : t().notesDialog.restartDocument}
- {/each}
-
-
+
{#if kind === 'footnote'}
+
+ {t().notesDialog.restart}
+ patch({ restart: e.currentTarget.value as NoteRestart })}>
+ {#each RESTARTS as r}
+ {r === 'page' ? t().notesDialog.restartPage : r === 'chapter' ? t().notesDialog.restartChapter : t().notesDialog.restartDocument}
+ {/each}
+
+
{t().notesDialog.position}
patch({ position: e.currentTarget.value === 'document' ? 'document' : 'page' })}>
diff --git a/src/lib/editor/extensions/notes.ts b/src/lib/editor/extensions/notes.ts
index 34608a4..4887303 100644
--- a/src/lib/editor/extensions/notes.ts
+++ b/src/lib/editor/extensions/notes.ts
@@ -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 = new Map();
+
+export function setNoteAnchorPages(next: ReadonlyMap): 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 {
- const seen: Record = { 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 = anchorPages,
+): Map {
+ const seen = new Map();
const out = new Map();
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;
}
diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts
index 8eec8db..dd0443f 100644
--- a/src/lib/editor/extensions/pageBreaks.ts
+++ b/src/lib/editor/extensions/pageBreaks.ts
@@ -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('.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();
}
diff --git a/src/lib/storage/noteSettings.ts b/src/lib/storage/noteSettings.ts
index d736361..0d4c2ea 100644
--- a/src/lib/storage/noteSettings.ts
+++ b/src/lib/storage/noteSettings.ts
@@ -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');
}
diff --git a/src/styles/editor.css b/src/styles/editor.css
index f78c074..c36bd56 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -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
diff --git a/tests/unit/notes.test.ts b/tests/unit/notes.test.ts
index b520207..e344788 100644
--- a/tests/unit/notes.test.ts
+++ b/tests/unit/notes.test.ts
@@ -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 =>
({ ...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', () => {