fix: a footnote keeps its place at the page foot after an edit above it

The placements key watched each note's top but not its doc position, so an
edit that shifted the note section without moving a note left the decoration
set naming stale positions — ProseMirror dropped it and every footnote fell
back to its static spot, stacked on top of the next one. The position now
rides the key, resolved once beside the top.
This commit is contained in:
Steffen Becker committed 2026-08-14 22:40:42 +02:00
1 parent d05459b0e0
commit ba827b4ce9
2 files changed
+19 -20

No files matched your search

+3 -2
View File
@@ -65,8 +65,9 @@ break the file's own rule that a pass must not read its own last answer:
4. The anchor's page comes from the leaf that holds it plus the breaks above it: every
break carries the leaf offset it applies from (`naturalY`), so an anchor below a line
split follows its half of the paragraph onto the next page.
5. `prevPlacementsKey` includes the note tops, or a frozen layout would leave every
footnote on the page it used to belong to.
5. The placements key includes each note's **doc position and top**: without the top a
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
+16 -18
View File
@@ -1618,15 +1618,22 @@ export const PageBreaks = Extension.create({
// Stack each page's notes up from its content end, in anchor order. The top
// of the block is where the separator is drawn (editor.css).
const noteTops = new Map<string, number>();
const notePlacements: { from: number; to: number; top: number; opensPage: boolean }[] = [];
const nextTop = new Map<number, number>();
const notePages = new Set<number>();
for (const note of footnotes) {
const page = placed.anchorPages.get(note.id);
if (!page) continue;
const base = placed.grid.bottomOf(page) - vm.bottom - (reserved.get(page) ?? 0) + sepSpace;
const top = nextTop.get(page) ?? base;
noteTops.set(note.id, top);
nextTop.set(page, top + note.height);
// Resolved here, not at the decoration below: the key must watch the position
// too, or an edit above the section leaves the decorations on a stale one.
const from = docPosBeforeElement(note.el);
const node = from === null ? null : editorView.state.doc.nodeAt(from);
if (from === null || !node) continue;
notePlacements.push({ from, to: from + node.nodeSize, top, opensPage: !notePages.has(page) });
notePages.add(page);
}
// Skip the decoration rebuild + dispatch when placements are identical to the
@@ -1637,9 +1644,9 @@ export const PageBreaks = Extension.create({
(collapsedTrailing ? `|c${collapsedTrailing.from}` : '') +
pageTopBlocks.map((b) => `|t${b.from}`).join('') +
Array.from(sectionInsets, ([f, s]) => `|i${f}:${s.left}:${s.right}`).join('') +
// The notes ride the same guard: a frozen layout whose reservation has moved
// on would leave every footnote on the page it used to belong to.
Array.from(noteTops, ([id, top]) => `|n${id}:${Math.round(top)}`).join('');
// The notes ride the same guard: a frozen layout would leave every footnote
// at the position, and on the page, it used to belong to.
notePlacements.map((n) => `|n${n.from}:${Math.round(n.top)}`).join('');
const placementsChanged =
placementsKey !== lastPlacementsKey && placementsKey !== prevPlacementsKey;
if (placementsChanged) {
@@ -1707,19 +1714,10 @@ export const PageBreaks = Extension.create({
}
// Each footnote to the foot of its anchor's page; the topmost of a page also
// carries the separator (editor.css draws it above the box).
const seenPages = new Set<number>();
for (const note of footnotes) {
const top = noteTops.get(note.id);
if (top === undefined) continue;
const from = docPosBeforeElement(note.el);
const node = from === null ? null : doc.nodeAt(from);
if (from === null || !node) continue;
const page = placed.anchorPages.get(note.id) as number;
const opensPage = !seenPages.has(page);
seenPages.add(page);
decoArray.push(Decoration.node(from, from + node.nodeSize, {
style: `top:${Math.round(top)}px`,
...(opensPage ? { 'data-note-page-first': 'true' } : {}),
for (const n of notePlacements) {
decoArray.push(Decoration.node(n.from, n.to, {
style: `top:${Math.round(n.top)}px`,
...(n.opensPage ? { 'data-note-page-first': 'true' } : {}),
}));
}