Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
4f3a719
fix(layout): render footnotes when the body fills a terminal page (SD…
tupizz Jun 9, 2026
c95425c
feat(footnote): text cursor over footnote and endnote content (SD-3400)
tupizz Jun 9, 2026
2783a04
feat(footnote): double-click a body reference to navigate to its note…
tupizz Jun 9, 2026
7054af0
feat(footnote): programmatic note activation for insert-and-focus (SD…
tupizz Jun 9, 2026
b00d1a4
feat(footnote): staged backspace/delete of body markers (SD-3400)
tupizz Jun 9, 2026
ad308bd
feat(footnote): area-delete removes the footnote on both sides (SD-3400)
tupizz Jun 9, 2026
1c670ec
fix(footnote): staged delete at run boundaries and dblclick via hit c…
tupizz Jun 9, 2026
f1989fe
feat(footnote): one-call insertFootnote command for custom toolbars (…
tupizz Jun 9, 2026
0870c8d
feat(footnote): interaction affordance, focus feedback, instant area-…
tupizz Jun 9, 2026
7c57693
style(footnote): soften the active-note highlight (SD-3400)
tupizz Jun 9, 2026
db7e851
feat(footnote): enlarge reference hit target and add dev insert butto…
tupizz Jun 9, 2026
911aabf
feat(footnote): smart-scroll to the note on navigation and insert (SD…
tupizz Jun 9, 2026
ec746ae
refactor(footnote): modularize note-session interactions (SD-3400)
tupizz Jun 9, 2026
e506275
fix(footnote): treat uninspectable note docs as non-empty (SD-3400)
tupizz Jun 10, 2026
5c4359e
refactor(footnote): complete the story-editor mock instead of guardin…
tupizz Jun 10, 2026
456f68a
fix(footnotes): note-story insert guard and full emptied-note removal
tupizz Jun 10, 2026
fe72bf8
fix(footnotes): prune note element on body-side staged delete
tupizz Jun 10, 2026
3e994e3
feat(footnotes): word-fidelity bootstrap for generated notes
tupizz Jun 10, 2026
308ccfb
feat(footnotes): navigate to note from cross-reference double-click
tupizz Jun 10, 2026
21ddc99
test(footnotes): pin backward drag selection symmetry in note sessions
tupizz Jun 10, 2026
c873494
fix(footnotes): resolve cross-references across flat bookmark pairs
tupizz Jun 10, 2026
04ee1db
feat(footnotes): document-default fallback for the note marker font
tupizz Jun 10, 2026
4e687c8
test(footnotes): behavior coverage for SD-3400 interactions
tupizz Jun 10, 2026
29157a6
test(footnotes): pin separator-variant roundtrip fidelity
tupizz Jun 10, 2026
c28d6d2
fix(footnotes): caret drift in multi-paragraph note sessions
tupizz Jun 10, 2026
b32cf50
test(footnotes): pin arrow navigation and caret tracking in notes
tupizz Jun 10, 2026
43ca6ea
fix(footnotes): keep note paragraph style when splitting in a session
tupizz Jun 10, 2026
b89805a
fix(footnotes): stale caret and style loss in note sessions
tupizz Jun 10, 2026
3fadc81
fix(footnotes): caret resolution against stale painted note ranges
tupizz Jun 10, 2026
e12313a
chore: remove temporary style probe spec
tupizz Jun 10, 2026
806e39b
fix(footnotes): arrow navigation skipping lines in note sessions
tupizz Jun 10, 2026
ff9207c
fix(painter): refresh position attributes on reused story fragments
tupizz Jun 10, 2026
2403016
fix(footnotes): native goal-column resolution for arrows in notes
tupizz Jun 10, 2026
6ae5237
fix(footnotes): resolve DOM globals via the document window
tupizz Jun 10, 2026
cb630b4
fix(footnotes): scope dblclick reference resolution to body fragments
tupizz Jun 10, 2026
6844904
fix(footnotes): support firefox caret-from-point in arrow navigation
tupizz Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/document-api/src/footnotes/footnotes.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export interface FootnoteGetInput {
}

export interface FootnoteInsertInput {
at: TextTarget;
/**
* Where to place the reference marker. Omit to insert at the current
* selection (caret position) — the natural target for toolbar actions.
*/
at?: TextTarget;
type: 'footnote' | 'endnote';
content: string;
}
Expand Down
74 changes: 53 additions & 21 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,38 @@ export async function incrementalLayout(
// just inflated dead reserve. Overflow now propagates naturally:
// any continuation beyond next-page capacity stays in
// pendingByColumn and lands on page+2, page+3, etc.
// Tallest per-column cluster demand for a page's anchored footnotes.
// The carry-forward bump counts only the FIRST LINE of the last entry
// (the rest continues onto the following page); the terminal-page bump
// needs full heights because there is nowhere to continue.
const clusterDemandFor = (targetPageIndex: number, lastEntryFirstLineOnly: boolean): number => {
let demand = 0;
for (let cIdx = 0; cIdx < columnCount; cIdx += 1) {
const ids = idsByColumn.get(targetPageIndex)?.get(cIdx) ?? [];
if (ids.length === 0) continue;
let columnCluster = 0;
for (let i = 0; i < ids.length; i += 1) {
const isLast = i === ids.length - 1;
columnCluster += lastEntryFirstLineOnly && isLast ? firstLineOf(ids[i]) : fullHeightOf(ids[i]);
if (i > 0) columnCluster += safeGap;
}
if (columnCluster > demand) demand = columnCluster;
}
return demand;
};

// Physical band cap for a page: content height minus a minimum body strip.
const maxBandFor = (targetPageIndex: number): number => {
const page = layoutForPages.pages?.[targetPageIndex];
const size = page?.size ?? layoutForPages.pageSize ?? DEFAULT_PAGE_SIZE;
const top = normalizeMargin(page?.margins?.top, DEFAULT_MARGINS.top);
const bottom = normalizeMargin(page?.margins?.bottom, DEFAULT_MARGINS.bottom);
const physicalContentHeight = Math.max(0, size.h - top - bottom);
return Math.max(0, physicalContentHeight - MIN_FOOTNOTE_BODY_HEIGHT * 20);
};

const bandOverhead = safeSeparatorSpacingBefore + continuationDividerHeight + safeTopPadding;

if (pageIndex + 1 < pageCount) {
let continuationDemand = 0;
pendingByColumn.forEach((entries) => {
Expand All @@ -1889,30 +1921,12 @@ export async function incrementalLayout(
});
});
// Next page's mandatory cluster demand (ordered minimum).
let nextClusterDemand = 0;
for (let cIdx = 0; cIdx < columnCount; cIdx += 1) {
const idsNext = idsByColumn.get(pageIndex + 1)?.get(cIdx) ?? [];
if (idsNext.length === 0) continue;
let columnCluster = 0;
for (let i = 0; i < idsNext.length; i += 1) {
const isLast = i === idsNext.length - 1;
columnCluster += isLast ? firstLineOf(idsNext[i]) : fullHeightOf(idsNext[i]);
if (i > 0) columnCluster += safeGap;
}
if (columnCluster > nextClusterDemand) nextClusterDemand = columnCluster;
}
const nextClusterDemand = clusterDemandFor(pageIndex + 1, true);
if (continuationDemand > 0 || nextClusterDemand > 0) {
const overhead = safeSeparatorSpacingBefore + continuationDividerHeight + safeTopPadding;
const nextPage = layoutForPages.pages?.[pageIndex + 1];
const nextPageSize = nextPage?.size ?? layoutForPages.pageSize ?? DEFAULT_PAGE_SIZE;
const nextTop = normalizeMargin(nextPage?.margins?.top, DEFAULT_MARGINS.top);
const nextBottomRaw = normalizeMargin(nextPage?.margins?.bottom, DEFAULT_MARGINS.bottom);
const physicalContentHeight = Math.max(0, nextPageSize.h - nextTop - nextBottomRaw);
const minBodyHeight = MIN_FOOTNOTE_BODY_HEIGHT * 20;
const nextPageMaxBand = Math.max(0, physicalContentHeight - minBodyHeight);
const nextPageMaxBand = maxBandFor(pageIndex + 1);
// The band has a single overhead block (separator + padding)
// whether or not we have a cluster.
const overheadForBand = nextClusterDemand > 0 || continuationDemand > 0 ? overhead : 0;
const overheadForBand = nextClusterDemand > 0 || continuationDemand > 0 ? bandOverhead : 0;
// Mandatory cluster room (cluster slices only, no overhead).
const clusterRoomPx =
nextClusterDemand > 0 ? Math.min(nextClusterDemand, Math.max(0, nextPageMaxBand - overheadForBand)) : 0;
Expand All @@ -1924,6 +1938,24 @@ export async function incrementalLayout(
const finalReserve = Math.min(clusterRoomPx + continuationToReservePx + overheadForBand, nextPageMaxBand);
reserves[pageIndex + 1] = Math.max(reserves[pageIndex + 1] ?? 0, Math.ceil(finalReserve));
}
} else {
// SD-3400: terminal-page footnote reserve bump.
// The carry-forward bump above only runs when there is a next page to
// drain onto. On the LAST page a footnote anchored here has nowhere to
// continue, so once the body fills the page the bodyMaxY-derived
// maxReserve collapses to ~0, placeFootnote can place nothing, and
// reserves[pageIndex] stays 0 — the body never yields and the footnote
// is silently dropped. When the placed reserve is short of the anchored
// demand, bump this page's reserve to that demand (capped at the
// physical band) so the next relayout pass shrinks the body and the
// footnote renders on its anchor page (matching Word). Guarded on
// `< clusterDemand` so pages whose footnote already placed fully are
// untouched — no gap/regression on non-dense pages.
const clusterDemand = clusterDemandFor(pageIndex, false);
if (clusterDemand > 0 && (reserves[pageIndex] ?? 0) < clusterDemand) {
const finalReserve = Math.min(clusterDemand + bandOverhead, maxBandFor(pageIndex));
reserves[pageIndex] = Math.max(reserves[pageIndex] ?? 0, Math.ceil(finalReserve));
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* SD-3400 (prerequisite): footnotes must still render when the body nearly fills
* a single (terminal) page.
*
* Root cause: the SD-2656 bodyMaxY-anchored reserve (`computeMaxFootnoteReserve`)
* makes the planner's max reserve equal the leftover body-region space
* (`pageH - bottomMargin - bodyMaxY`). When the body fills the page that leftover
* is ~0, so the footnote can't be placed; on a single-page document there is no
* continuation page to overflow onto, and the footnote is silently dropped.
*
* Reproduced live: `Footnote tests.docx` (body fills ~98% of the body region,
* leaving ~17px) renders 0 of its 6 footnotes. `basic-footnotes.docx` (tiny body)
* and multi-page docs render fine — so this is specifically the dense terminal-page
* case.
*
* Invariant: a footnote anchored on a page must render its body. The body must
* yield space (break earlier / grow a page) so the footnote fits on its anchor
* page, rather than being dropped.
*/

import { describe, it, expect, vi } from 'vitest';
import type { FlowBlock, Measure } from '@superdoc/contracts';
import { incrementalLayout } from '../src/incrementalLayout';

const makeParagraph = (id: string, text: string, pmStart: number): FlowBlock => ({
kind: 'paragraph',
id,
runs: [{ text, fontFamily: 'Arial', fontSize: 12, pmStart, pmEnd: pmStart + text.length }],
});

const makeMeasure = (lineHeight: number, lineCount: number): Measure => ({
kind: 'paragraph',
lines: Array.from({ length: lineCount }, (_, i) => ({
fromRun: 0,
fromChar: i,
toRun: 0,
toChar: i + 1,
width: 200,
ascent: lineHeight * 0.8,
descent: lineHeight * 0.2,
lineHeight,
})),
totalHeight: lineCount * lineHeight,
});

const countFootnoteFragments = (layout: { pages: Array<{ fragments: Array<{ blockId?: string }> }> }, idPrefix: string) => {
let count = 0;
for (const page of layout.pages) {
for (const f of page.fragments) {
if (String(f.blockId).startsWith(idPrefix)) count += 1;
}
}
return count;
};

describe('SD-3400 prerequisite: footnote render on a dense terminal page', () => {
it('renders the footnote body even when the body nearly fills the only page', async () => {
// Page geometry: body region = 600px (h 744, margins 72/72), line height 20.
// 30 body lines × 20 = 600px → the body fills the region exactly, leaving ~0
// reserve. The footnote ref is mid-body (line 10), so it is anchored on page 1.
const BODY_LINES = 30;
const LINE_H = 20;
const FOOTNOTE_LINES = 5;
const FOOTNOTE_LINE_H = 12;

let pos = 0;
const blocks: FlowBlock[] = [];
for (let i = 0; i < BODY_LINES; i += 1) {
const text = `Body line ${i + 1}.`;
blocks.push(makeParagraph(`body-${i}`, text, pos));
pos += text.length + 1;
}
const refBlock = blocks[9];
const refPos = (refBlock.kind === 'paragraph' ? (refBlock.runs?.[0]?.pmStart ?? 0) : 0) + 2;
const ftBlock = makeParagraph('footnote-1-0-paragraph', 'Footnote body content here.', 0);

const measureBlock = vi.fn(async (b: FlowBlock) => {
if (b.id.startsWith('footnote-')) return makeMeasure(FOOTNOTE_LINE_H, FOOTNOTE_LINES);
return makeMeasure(LINE_H, 1);
});

const margins = { top: 72, right: 72, bottom: 72, left: 72 };
const result = await incrementalLayout(
[],
null,
blocks,
{
pageSize: { w: 612, h: 600 + margins.top + margins.bottom },
margins,
footnotes: {
refs: [{ id: '1', pos: refPos }],
blocksById: new Map([['1', [ftBlock]]]),
topPadding: 6,
dividerHeight: 6,
},
},
measureBlock,
);

// The footnote body must render somewhere (it is currently dropped → 0).
const noteFragments = countFootnoteFragments(result.layout, 'footnote-1');
expect(noteFragments).toBeGreaterThan(0);

// And its separator should render too, confirming the band exists.
const sepFragments = countFootnoteFragments(result.layout, 'footnote-separator');
expect(sepFragments).toBeGreaterThan(0);
});
});
69 changes: 69 additions & 0 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type {
ListBlock,
} from '@superdoc/contracts';
import {
computeLinePmRange,
LAYOUT_BOUNDARY_SCHEMA,
buildLayoutSourceIdentityForFragment,
expandRunsForInlineNewlines,
Expand All @@ -65,6 +66,7 @@ import {
containerStyles,
containerStylesHorizontal,
ensureFieldAnnotationStyles,
ensureFootnoteStyles,
ensureFormattingMarksStyles,
ensureImageSelectionStyles,
ensureLinkStyles,
Expand Down Expand Up @@ -1301,6 +1303,7 @@ export class DomPainter {
ensureSdtContainerStyles(doc);
ensureImageSelectionStyles(doc);
ensureMathMencloseStyles(doc);
ensureFootnoteStyles(doc);
if (!this.isSemanticFlow && this.options.ruler?.enabled) {
ensureRulerStyles(doc);
}
Expand Down Expand Up @@ -2440,6 +2443,14 @@ export class DomPainter {
pageEl.replaceChild(replacement, current.element);
current.element = replacement;
current.signature = resolvedSig;
} else if (isNonBodyStoryBlockId(fragment.blockId)) {
// Story fragments (notes, headers/footers) use story-local positions:
// the body transaction mapping does not apply, but the resolved item
// carries FRESH story positions every paint. Shift the painted
// attributes by the fresh-vs-painted delta so reused fragments never
// serve stale positions (SD-3400: stale note ranges broke caret,
// selection, and arrow navigation downstream).
this.updateStoryPositionAttributes(current.element, resolvedItem);
} else if (this.currentMapping) {
// Fragment NOT rebuilt - update position attributes to reflect document changes
this.updatePositionAttributes(current.element, this.currentMapping);
Expand Down Expand Up @@ -2487,6 +2498,64 @@ export class DomPainter {
* using the transaction's mapping. Skips header/footer content (separate PM coordinate space).
* Also skips fragments that end before the edit point (their positions don't change).
*/
/**
* Refreshes data-pm-start/data-pm-end on a REUSED story fragment from the
* fresh resolved item. Story positions are local to their story document,
* so the body transaction mapping cannot update them; instead the uniform
* shift between the fresh first position and the painted one is applied.
* Exact for unchanged blocks (positions inside one block shift uniformly).
*/
private updateStoryPositionAttributes(fragmentEl: HTMLElement, resolvedItem: ResolvedPaintItem | undefined): void {
if (!resolvedItem || resolvedItem.kind !== 'fragment') return;

// Fragment-scoped fresh landmark: the pm start of THIS fragment's first
// line (matches what render-line stamps as the first painted attribute,
// including continuation fragments that start mid-block).
let freshStart: number | undefined;
const block = 'block' in resolvedItem ? resolvedItem.block : undefined;
const firstLine = 'content' in resolvedItem ? resolvedItem.content?.lines?.[0]?.line : undefined;
if (block && firstLine) {
const range = computeLinePmRange(block, firstLine);
if (typeof range.pmStart === 'number' && Number.isFinite(range.pmStart)) {
freshStart = range.pmStart;
}
}
if (freshStart == null && block) {
const runs = (block as { runs?: Array<{ pmStart?: number | null }> }).runs;
if (Array.isArray(runs)) {
for (const run of runs) {
if (typeof run?.pmStart === 'number' && Number.isFinite(run.pmStart)) {
freshStart = run.pmStart;
break;
}
}
}
}
if (freshStart == null || !Number.isFinite(freshStart)) return;

const elements = [fragmentEl, ...Array.from(fragmentEl.querySelectorAll<HTMLElement>('[data-pm-start], [data-pm-end]'))];
let paintedStart = Infinity;
for (const el of elements) {
const start = Number(el.dataset.pmStart);
if (Number.isFinite(start)) paintedStart = Math.min(paintedStart, start);
}
if (!Number.isFinite(paintedStart)) return;

const delta = freshStart - paintedStart;
if (delta === 0) return;

for (const el of elements) {
const start = Number(el.dataset.pmStart);
if (el.dataset.pmStart !== undefined && el.dataset.pmStart !== '' && Number.isFinite(start)) {
el.dataset.pmStart = String(start + delta);
}
const end = Number(el.dataset.pmEnd);
if (el.dataset.pmEnd !== undefined && el.dataset.pmEnd !== '' && Number.isFinite(end)) {
el.dataset.pmEnd = String(end + delta);
}
}
}

private updatePositionAttributes(fragmentEl: HTMLElement, mapping: PositionMapping): void {
// Skip header/footer elements (they use a separate PM coordinate space)
if (fragmentEl.closest('.superdoc-page-header, .superdoc-page-footer')) {
Expand Down
30 changes: 29 additions & 1 deletion packages/layout-engine/painters/dom/src/styles.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { ensureSdtContainerStyles, ensureTrackChangeStyles, lineStyles } from './styles.js';
import { ensureFootnoteStyles, ensureSdtContainerStyles, ensureTrackChangeStyles, lineStyles } from './styles.js';

describe('lineStyles', () => {
it('sets height and lineHeight from the argument', () => {
Expand All @@ -14,6 +14,34 @@ describe('lineStyles', () => {
});
});

describe('ensureFootnoteStyles', () => {
it('renders a text cursor over footnote and endnote note content (SD-3400)', () => {
ensureFootnoteStyles(document);

const styleEl = document.querySelector('[data-superdoc-footnote-styles="true"]');
const cssText = styleEl?.textContent ?? '';

// Note fragments are generic .superdoc-fragment elements keyed by block-id prefix.
expect(cssText).toContain('[data-block-id^="footnote-"]');
expect(cssText).toContain('[data-block-id^="endnote-"]');
expect(cssText).toContain('[data-block-id^="__sd_semantic_footnote-"]');
expect(cssText).toContain('cursor: text;');
});

it('signals clickability on body reference markers and highlights the active note (SD-3400)', () => {
ensureFootnoteStyles(document);

const styleEl = document.querySelector('[data-superdoc-footnote-styles="true"]');
const cssText = styleEl?.textContent ?? '';

expect(cssText).toContain('[data-note-reference]');
expect(cssText).toContain('cursor: pointer;');
expect(cssText).toContain('[data-note-reference]:hover');
expect(cssText).toContain('.sd-note-session-active');
expect(cssText).toContain('sd-note-activate-pulse');
});
});

describe('ensureSdtContainerStyles', () => {
it('exposes hover border tokens for structured content overrides', () => {
ensureSdtContainerStyles(document);
Expand Down
Loading
Loading