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:
1 parent
ba827b4ce9
commit
bf564fbab4
7 files changed
+116
-32
No files matched your search
@@ -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' })}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user