diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 62699b03ba..8be4e0e60e 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -285,6 +285,32 @@ export function buildMultiSectionIdentifier( identifier.footerIds.odd = identifier.footerIds.odd ?? converterIds.footerIds.odd ?? null; } + // PM section metadata often lists only headerReference types present in that sectPr snapshot; + // converter.headerIds still has even/odd from the full package. Merge those into per-section + // rows so getHeaderFooterTypeForSection sees hasEven and returns 'even' on even pages. + if (converterIds?.headerIds) { + const c = converterIds.headerIds; + for (const [idx, row] of identifier.sectionHeaderIds) { + identifier.sectionHeaderIds.set(idx, { + default: row.default ?? c.default ?? null, + first: row.first ?? c.first ?? null, + even: row.even ?? c.even ?? null, + odd: row.odd ?? c.odd ?? null, + }); + } + } + if (converterIds?.footerIds) { + const c = converterIds.footerIds; + for (const [idx, row] of identifier.sectionFooterIds) { + identifier.sectionFooterIds.set(idx, { + default: row.default ?? c.default ?? null, + first: row.first ?? c.first ?? null, + even: row.even ?? c.even ?? null, + odd: row.odd ?? c.odd ?? null, + }); + } + } + return identifier; } diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 4f82c339fc..b83e0f9784 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -1742,6 +1742,8 @@ export async function incrementalLayout( footnoteReservedByPageIndex, headerContentHeights, footerContentHeights, + headerContentHeightsByRId, + footerContentHeightsByRId, remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), }); diff --git a/packages/layout-engine/layout-bridge/test/footnoteRelayoutKeepsHeaderHeightsByRId.test.ts b/packages/layout-engine/layout-bridge/test/footnoteRelayoutKeepsHeaderHeightsByRId.test.ts new file mode 100644 index 0000000000..eb238982fe --- /dev/null +++ b/packages/layout-engine/layout-bridge/test/footnoteRelayoutKeepsHeaderHeightsByRId.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { FlowBlock, Measure } from '@superdoc/contracts'; +import type { HeaderFooterConstraints } from '@superdoc/layout-engine'; +import * as layoutEngine from '@superdoc/layout-engine'; +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, textLength: number): Measure => ({ + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: textLength, + width: 200, + ascent: lineHeight * 0.8, + descent: lineHeight * 0.2, + lineHeight, + }, + ], + totalHeight: lineHeight, +}); + +const makeMultiLineMeasure = (lineHeight: number, lineCount: number): Measure => { + const 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, + })); + return { + kind: 'paragraph', + lines, + totalHeight: lineCount * lineHeight, + }; +}; + +/** + * Footnote reserve relayout must keep headerContentHeightsByRId / footerContentHeightsByRId. + * Otherwise per-rId header height is dropped, topMargin is not inflated, and body overlaps a tall header. + */ +describe('Footnote relayout preserves headerContentHeightsByRId', () => { + it('passes by-RId header maps on every layoutDocument call that reserves footnote space', async () => { + const BODY_LINE_HEIGHT = 20; + const FOOTNOTE_LINE_HEIGHT = 12; + const HEADER_CONTENT_HEIGHT = 100; + const LINES_ON_PAGE_1_WITHOUT_RESERVE = 12; + const FOOTNOTE_LINES = 5; + + const headerBlock = makeParagraph('hdr-rId1-line', 'Tall header line', 0); + + let pos = 0; + const bodyBlocks: FlowBlock[] = []; + for (let i = 0; i < LINES_ON_PAGE_1_WITHOUT_RESERVE; i += 1) { + const text = `Line ${i + 1}.`; + bodyBlocks.push(makeParagraph(`body-${i}`, text, pos)); + pos += text.length + 1; + } + const refPos = pos - 2; + const footnoteBlock = makeParagraph( + 'footnote-1-0-paragraph', + 'Footnote content that spans multiple lines here.', + 0, + ); + + const measureBlock = vi.fn(async (block: FlowBlock) => { + if (block.id.startsWith('hdr-')) { + return makeMeasure(HEADER_CONTENT_HEIGHT, block.runs?.[0]?.text?.length ?? 1); + } + if (block.id.startsWith('footnote-')) { + return makeMultiLineMeasure(FOOTNOTE_LINE_HEIGHT, FOOTNOTE_LINES); + } + const textLength = block.kind === 'paragraph' ? (block.runs?.[0]?.text?.length ?? 1) : 1; + return makeMeasure(BODY_LINE_HEIGHT, textLength); + }); + + const contentHeight = 240; + const margins = { top: 72, right: 72, bottom: 72, left: 72, header: 72, footer: 72 }; + const pageHeight = contentHeight + margins.top + margins.bottom; + const pageWidth = 612; + const contentWidth = pageWidth - margins.left - margins.right; + + const constraints: HeaderFooterConstraints = { + width: contentWidth, + height: margins.top, + pageWidth, + pageHeight, + margins: { left: margins.left, right: margins.right, top: margins.top, bottom: margins.bottom }, + }; + + const layoutDocSpy = vi.spyOn(layoutEngine, 'layoutDocument'); + + const { layout } = await incrementalLayout( + [], + null, + bodyBlocks, + { + pageSize: { w: pageWidth, h: pageHeight }, + margins, + sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'rId1' } }], + footnotes: { + refs: [{ id: '1', pos: refPos }], + blocksById: new Map([['1', [footnoteBlock]]]), + topPadding: 4, + dividerHeight: 2, + }, + }, + measureBlock, + { + headerBlocksByRId: new Map([['rId1', [headerBlock]]]), + constraints, + }, + ); + + const reserveCalls = layoutDocSpy.mock.calls.filter((call) => { + const opts = call[2] as { footnoteReservedByPageIndex?: number[] }; + return opts?.footnoteReservedByPageIndex?.some((h) => h > 0); + }); + layoutDocSpy.mockRestore(); + + expect(reserveCalls.length).toBeGreaterThanOrEqual(1); + for (const call of reserveCalls) { + const opts = call[2] as { + headerContentHeightsByRId?: Map; + }; + expect(opts.headerContentHeightsByRId).toBeInstanceOf(Map); + expect(opts.headerContentHeightsByRId?.get('rId1')).toBeGreaterThanOrEqual(HEADER_CONTENT_HEIGHT - 1); + } + + const page1 = layout.pages[0]; + const headerDistance = page1.margins?.header ?? margins.header; + const minBodyTop = Math.max(margins.top, headerDistance + HEADER_CONTENT_HEIGHT); + const firstBody = page1.fragments.find((f) => String(f.blockId).startsWith('body-') && f.kind === 'para'); + expect(firstBody && 'y' in firstBody && typeof firstBody.y === 'number').toBe(true); + expect((firstBody as { y: number }).y).toBeGreaterThanOrEqual(minBodyTop - 1); + }); +}); diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 7d44000f83..d1905c72b8 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -397,10 +397,22 @@ describe('headerFooterUtils', () => { expect(identifier.headerIds.first).toBe('section-h-first'); // Converter IDs should only fill in gaps expect(identifier.headerIds.even).toBe('converter-h-even'); + expect(identifier.sectionHeaderIds.get(0)?.even).toBe('converter-h-even'); expect(identifier.footerIds.default).toBe('section-f-default'); expect(identifier.footerIds.odd).toBe('converter-f-odd'); }); + it('fills per-section header maps from converter when section metadata omits even', () => { + const sectionMetadata: SectionMetadata[] = [{ sectionIndex: 0, headerRefs: { default: 'r-default' } }]; + const identifier = buildMultiSectionIdentifier( + sectionMetadata, + { alternateHeaders: true }, + { headerIds: { default: 'r-default', even: 'r-even' } }, + ); + expect(identifier.sectionHeaderIds.get(0)?.even).toBe('r-even'); + expect(getHeaderFooterTypeForSection(2, 0, identifier, { kind: 'header', sectionPageNumber: 2 })).toBe('even'); + }); + it('should handle missing converterIds parameter gracefully', () => { const sectionMetadata: SectionMetadata[] = [ { diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 43c58b74a4..d3079c907e 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -16,6 +16,7 @@ import type { PageBreakBlock, TableBlock, TableMeasure, + SectionMetadata, } from '@superdoc/contracts'; import { layoutDocument, layoutHeaderFooter, type LayoutOptions } from './index.js'; @@ -818,6 +819,156 @@ describe('layoutDocument', () => { expect(fragment.pmEnd).toBe(12); }); + it('inflates top margin using transitive inherited first-header rId (multi-hop section metadata)', () => { + const m = { top: 72, bottom: 72, left: 72, right: 72, header: 72, footer: 72 }; + const sb0: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-0', + type: 'continuous', + margins: m, + headerRefs: { default: 'd0', first: 'f0' }, + attrs: { isFirstSection: true, sectionIndex: 0 }, + }; + const sb1: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-1', + type: 'nextPage', + margins: m, + headerRefs: { default: 'd1' }, + attrs: { sectionIndex: 1 }, + }; + const sb2: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-2', + type: 'nextPage', + margins: m, + headerRefs: { default: 'd2' }, + attrs: { sectionIndex: 2 }, + }; + + const lineHeight = 40; + const blocks: FlowBlock[] = [ + sb0, + { kind: 'paragraph', id: 'p0', runs: [] }, + sb1, + { kind: 'paragraph', id: 'p1', runs: [] }, + sb2, + { kind: 'paragraph', id: 'p2', runs: [] }, + ]; + const measures: Measure[] = [ + { kind: 'sectionBreak' }, + makeMeasure(Array(20).fill(lineHeight)), + { kind: 'sectionBreak' }, + makeMeasure(Array(20).fill(lineHeight)), + { kind: 'sectionBreak' }, + makeMeasure([lineHeight]), + ]; + + const tallFirst = 220; + const sectionMetadata: SectionMetadata[] = [ + { sectionIndex: 0, titlePg: true, headerRefs: { default: 'd0', first: 'f0' } }, + { sectionIndex: 1, titlePg: true, headerRefs: { default: 'd1' } }, + { sectionIndex: 2, titlePg: true, headerRefs: { default: 'd2' } }, + ]; + + const layout = layoutDocument(blocks, measures, { + pageSize: { w: 500, h: 600 }, + margins: m, + sectionMetadata, + headerContentHeightsByRId: new Map([['f0', tallFirst]]), + }); + + const section2Page = layout.pages.find((p) => p.sectionIndex === 2); + expect(section2Page).toBeDefined(); + expect(section2Page!.margins.top).toBeGreaterThanOrEqual(72 + tallFirst - 1); + }); + + it('uses physical page number for even/odd variant selection when oddEvenHeadersFooters is enabled', () => { + const m = { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 }; + + // Single section with alternateHeaders. Odd physical pages get 'odd' variant, + // even physical pages get 'even' variant. Each variant has a different header + // height so we can verify the correct one drives margin inflation. + const sb0: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-0', + type: 'continuous', + margins: m, + headerRefs: { default: 'd0', even: 'e0', odd: 'o0' }, + attrs: { isFirstSection: true, sectionIndex: 0 }, + }; + + const lineHeight = 20; + const blocks: FlowBlock[] = [sb0, { kind: 'paragraph', id: 'p0', runs: [] }]; + const measures: Measure[] = [ + { kind: 'sectionBreak' }, + makeMeasure(Array(80).fill(lineHeight)), // enough content for multiple pages + ]; + + const evenHeight = 120; + const oddHeight = 80; + const sectionMetadata: SectionMetadata[] = [ + { sectionIndex: 0, headerRefs: { default: 'd0', even: 'e0', odd: 'o0' } }, + ]; + + const layout = layoutDocument(blocks, measures, { + pageSize: { w: 612, h: 792 }, + margins: m, + sectionMetadata, + oddEvenHeadersFooters: true, + headerContentHeightsByRId: new Map([ + ['e0', evenHeight], + ['o0', oddHeight], + ]), + }); + + expect(layout.pages.length).toBeGreaterThanOrEqual(2); + + // Page 1 (physical 1, odd): odd variant → margin inflated by oddHeight + const page1 = layout.pages[0]; + expect(page1.margins.top).toBeGreaterThanOrEqual(36 + oddHeight - 1); + + // Page 2 (physical 2, even): even variant → margin inflated by evenHeight + const page2 = layout.pages[1]; + expect(page2.margins.top).toBeGreaterThanOrEqual(36 + evenHeight - 1); + // Even margin should be larger than odd margin since evenHeight > oddHeight + expect(page2.margins.top).toBeGreaterThan(page1.margins.top); + }); + + it('does not use even/odd variants when oddEvenHeadersFooters is not set', () => { + const m = { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 }; + const sb0: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-0', + type: 'continuous', + margins: m, + headerRefs: { default: 'd0', even: 'e0' }, + attrs: { isFirstSection: true, sectionIndex: 0 }, + }; + + const lineHeight = 20; + const blocks: FlowBlock[] = [sb0, { kind: 'paragraph', id: 'p0', runs: [] }]; + const measures: Measure[] = [{ kind: 'sectionBreak' }, makeMeasure(Array(80).fill(lineHeight))]; + + const tallEven = 200; + const layout = layoutDocument(blocks, measures, { + pageSize: { w: 612, h: 792 }, + margins: m, + sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'd0', even: 'e0' } }], + // oddEvenHeadersFooters NOT set + headerContentHeightsByRId: new Map([ + ['e0', tallEven], + ['d0', 10], + ]), + }); + + // Without oddEvenHeadersFooters, all pages use 'default' variant — + // margins should NOT be inflated by the even header height + for (const page of layout.pages) { + expect(page.margins.top).toBeLessThan(36 + tallEven); + } + }); + it('applies section break margins to subsequent pages', () => { const sectionBreakBlock: FlowBlock = { kind: 'sectionBreak', diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts index 23d1f9412d..70fc7274e0 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts @@ -24,6 +24,12 @@ const makeBlock = (id: string): FlowBlock => ({ runs: [{ text: id, fontFamily: 'Arial', fontSize: 12 }], }); +const makeParagraph = (id: string, text: string): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [{ text, fontFamily: 'Arial', fontSize: 12 }], +}); + const makeMeasure = (): Measure => ({ kind: 'paragraph', lines: [ @@ -129,4 +135,75 @@ describe('layoutPerRIdHeaderFooters', () => { expect(deps.headerLayoutsByRId.has('rId-header-first')).toBe(true); expect(deps.headerLayoutsByRId.has('rId-header-orphan')).toBe(false); }); + + it('lays out even/odd header refs inherited from earlier sections', async () => { + const deps = { + headerLayoutsByRId: new Map(), + footerLayoutsByRId: new Map(), + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 0 }, + { number: 3, fragments: [], sectionIndex: 1 }, + ], + }; + + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36 }, + headerRefs: { + default: 'rId-default', + even: 'rId-even', + odd: 'rId-odd', + }, + }, + { + sectionIndex: 1, + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36 }, + headerRefs: { + default: 'rId-default-s1', + // even and odd NOT defined — should be inherited from section 0 + }, + }, + ]; + + await layoutPerRIdHeaderFooters( + { + headerBlocksByRId: new Map([ + ['rId-default', [makeParagraph('h-default', 'Default')]], + ['rId-even', [makeParagraph('h-even', 'Even header')]], + ['rId-odd', [makeParagraph('h-odd', 'Odd header')]], + ['rId-default-s1', [makeParagraph('h-default-s1', 'Section 1 default')]], + ]), + footerBlocksByRId: new Map(), + constraints: { + width: 468, + height: 648, + pageWidth: 612, + pageHeight: 792, + margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 }, + overflowBaseHeight: 36, + }, + }, + layout, + sectionMetadata, + deps, + ); + + // Section 0 should have default, even, and odd layouts + expect(deps.headerLayoutsByRId.has('rId-default::s0')).toBe(true); + expect(deps.headerLayoutsByRId.has('rId-even::s0')).toBe(true); + expect(deps.headerLayoutsByRId.has('rId-odd::s0')).toBe(true); + + // Section 1 should have its own default, plus inherited even and odd from section 0 + expect(deps.headerLayoutsByRId.has('rId-default-s1::s1')).toBe(true); + expect(deps.headerLayoutsByRId.has('rId-even::s1')).toBe(true); + expect(deps.headerLayoutsByRId.has('rId-odd::s1')).toBe(true); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts index e705c4a41b..2433a0ed45 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts @@ -189,36 +189,6 @@ function collectReferencedRIdsBySection(effectiveRefsBySection: Map { - const result = new Map(); - let inheritedDefaultRId: string | undefined; - - for (const section of sectionMetadata) { - const refs = getRefsForKind(section, kind); - const explicitDefaultRId = refs?.default; - - if (explicitDefaultRId) { - inheritedDefaultRId = explicitDefaultRId; - } - - if (inheritedDefaultRId) { - result.set(section.sectionIndex, inheritedDefaultRId); - } - } - - return result; -} - /** * Layout header/footer blocks per rId, respecting per-section margins. * @@ -411,7 +381,7 @@ async function layoutWithPerSectionConstraints( ): Promise { if (!blocksByRId) return; - const defaultRIdPerSection = resolveDefaultRIdPerSection(sectionMetadata, kind); + const effectiveRefsBySection = buildEffectiveRefsBySection(sectionMetadata, kind); // Extract table width specs per rId (SD-1837). // Word allows tables in headers/footers to extend beyond content margins. @@ -433,32 +403,37 @@ async function layoutWithPerSectionConstraints( >(); for (const section of sectionMetadata) { - const rId = defaultRIdPerSection.get(section.sectionIndex); - if (!rId || !blocksByRId.has(rId)) continue; - - // Resolve the minimum width needed for tables in this section. - // For pct tables, this depends on the section's content width. - const contentWidth = buildSectionContentWidth(section, fallbackConstraints); - const tableWidthSpec = tableWidthSpecByRId.get(rId); - const tableMinWidth = resolveTableMinWidth(tableWidthSpec, contentWidth); - const sectionConstraints = buildConstraintsForSection(section, fallbackConstraints, tableMinWidth || undefined); - const effectiveWidth = sectionConstraints.width; - // Include vertical geometry in the key so sections with different page heights, - // vertical margins, or header distance get separate layouts (page-relative anchors - // and header band origin resolve differently). - const groupKey = `${rId}::w${effectiveWidth}::ph${sectionConstraints.pageHeight ?? ''}::mt${sectionConstraints.margins?.top ?? ''}::mb${sectionConstraints.margins?.bottom ?? ''}::mh${sectionConstraints.margins?.header ?? ''}`; - - let group = groups.get(groupKey); - if (!group) { - group = { - sectionConstraints, - sectionIndices: [], - rId, - effectiveWidth, - }; - groups.set(groupKey, group); + const refs = effectiveRefsBySection.get(section.sectionIndex); + if (!refs) continue; + + for (const variant of HEADER_FOOTER_VARIANTS) { + const rId = refs[variant]; + if (!rId || !blocksByRId.has(rId)) continue; + + // Resolve the minimum width needed for tables in this section. + // For pct tables, this depends on the section's content width. + const contentWidth = buildSectionContentWidth(section, fallbackConstraints); + const tableWidthSpec = tableWidthSpecByRId.get(rId); + const tableMinWidth = resolveTableMinWidth(tableWidthSpec, contentWidth); + const sectionConstraints = buildConstraintsForSection(section, fallbackConstraints, tableMinWidth || undefined); + const effectiveWidth = sectionConstraints.width; + // Include vertical geometry in the key so sections with different page heights, + // vertical margins, or header distance get separate layouts (page-relative anchors + // and header band origin resolve differently). + const groupKey = `${rId}::w${effectiveWidth}::ph${sectionConstraints.pageHeight ?? ''}::mt${sectionConstraints.margins?.top ?? ''}::mb${sectionConstraints.margins?.bottom ?? ''}::mh${sectionConstraints.margins?.header ?? ''}`; + + let group = groups.get(groupKey); + if (!group) { + group = { + sectionConstraints, + sectionIndices: [], + rId, + effectiveWidth, + }; + groups.set(groupKey, group); + } + group.sectionIndices.push(section.sectionIndex); } - group.sectionIndices.push(section.sectionIndex); } // Measure and layout each unique (rId, effectiveWidth) group diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 64498efb5d..9670b78cfd 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -20,7 +20,7 @@ import type { Mapping } from 'prosemirror-transform'; import { Editor } from '../Editor.js'; import { EventEmitter } from '../EventEmitter.js'; import { EpochPositionMapper } from './layout/EpochPositionMapper.js'; -import { DomPositionIndex } from '../../dom-observer/DomPositionIndex.js'; +import { DomPositionIndex, isFootnotePaintedBlockHost } from '../../dom-observer/DomPositionIndex.js'; import { DomPositionIndexObserverManager } from '../../dom-observer/DomPositionIndexObserverManager.js'; import { computeDomCaretPageLocal as computeDomCaretPageLocalFromDom, @@ -118,6 +118,7 @@ import { isHeaderFooterPartId } from '../parts/adapters/header-footer-part-descr import type { PartChangedEvent } from '../parts/types.js'; import { isInRegisteredSurface } from './utils/uiSurfaceRegistry.js'; import { buildSemanticFootnoteBlocks } from './semantic-flow-footnotes.js'; +import { isFootnoteLayoutBlockId } from './semantic-flow-constants.js'; type ThreadAnchorScrollPlan = { achievedClientY: number; @@ -2432,18 +2433,7 @@ export class PresentationEditor extends EventEmitter { const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; if (layout && sessionMode === 'body') { - let pageIndex: number | null = null; - for (let idx = 0; idx < layout.pages.length; idx++) { - const page = layout.pages[idx]; - for (const fragment of page.fragments) { - const frag = fragment as { pmStart?: number; pmEnd?: number }; - if (frag.pmStart != null && frag.pmEnd != null && clampedPos >= frag.pmStart && clampedPos <= frag.pmEnd) { - pageIndex = idx; - break; - } - } - if (pageIndex != null) break; - } + const pageIndex = this.#findPageIndexForPosition(layout, clampedPos); if (pageIndex != null) { const pageEl = getPageElementByIndex(this.#viewportHost, pageIndex); @@ -2564,6 +2554,26 @@ export class PresentationEditor extends EventEmitter { }; } + /** + * Find the 0-based page index whose body fragments contain `pos`, skipping footnote + * layout blocks. Returns null when no fragment reports pmStart/pmEnd for `pos`. + */ + #findPageIndexForPosition(layout: Layout, pos: number): number | null { + for (let idx = 0; idx < layout.pages.length; idx++) { + const page = layout.pages[idx]; + for (const fragment of page.fragments) { + if (isFootnoteLayoutBlockId((fragment as { blockId?: string }).blockId)) { + continue; + } + const frag = fragment as { pmStart?: number; pmEnd?: number }; + if (frag.pmStart != null && frag.pmEnd != null && pos >= frag.pmStart && pos <= frag.pmEnd) { + return idx; + } + } + } + return null; + } + /** * Find the DOM element containing a specific document position. * Returns the most specific (smallest range) matching element. @@ -2578,6 +2588,7 @@ export class PresentationEditor extends EventEmitter { // Skip header/footer fragments — their PM positions come from a separate // document and can overlap with body positions, causing incorrect matches. if (htmlEl.closest('.superdoc-page-header, .superdoc-page-footer')) continue; + if (isFootnotePaintedBlockHost(htmlEl)) continue; const start = Number(htmlEl.dataset.pmStart); const end = Number(htmlEl.dataset.pmEnd); @@ -2630,18 +2641,7 @@ export class PresentationEditor extends EventEmitter { const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; if (!layout || sessionMode !== 'body') return false; - let pageIndex: number | null = null; - for (let idx = 0; idx < layout.pages.length; idx++) { - const page = layout.pages[idx]; - for (const fragment of page.fragments) { - const frag = fragment as { pmStart?: number; pmEnd?: number }; - if (frag.pmStart != null && frag.pmEnd != null && clampedPos >= frag.pmStart && clampedPos <= frag.pmEnd) { - pageIndex = idx; - break; - } - } - if (pageIndex != null) break; - } + const pageIndex = this.#findPageIndexForPosition(layout, clampedPos); if (pageIndex == null) return false; // Trigger virtualization to render the page @@ -5366,6 +5366,8 @@ export class PresentationEditor extends EventEmitter { this.#layoutOptions.pageSize = pageSize; this.#layoutOptions.margins = margins; const flowMode = this.#layoutOptions.flowMode ?? 'paginated'; + const oddEvenHeadersFooters = + (this.#editor as EditorWithConverter)?.converter?.pageStyles?.alternateHeaders === true; const resolvedMargins = { top: margins.top!, @@ -5405,6 +5407,7 @@ export class PresentationEditor extends EventEmitter { marginBottom: semanticMargins.bottom, }, sectionMetadata, + ...(oddEvenHeadersFooters ? { oddEvenHeadersFooters: true } : {}), }; } @@ -5416,6 +5419,7 @@ export class PresentationEditor extends EventEmitter { margins: resolvedMargins, ...(columns ? { columns } : {}), sectionMetadata, + ...(oddEvenHeadersFooters ? { oddEvenHeadersFooters: true } : {}), }; } @@ -6722,20 +6726,7 @@ export class PresentationEditor extends EventEmitter { // Fallback: scan pages to find which one contains this position via fragments // Note: pmStart/pmEnd are only present on some fragment types (ParaFragment, ImageFragment, DrawingFragment) - const pos = selection.from; - for (let pageIdx = 0; pageIdx < layout.pages.length; pageIdx++) { - const page = layout.pages[pageIdx]; - for (const fragment of page.fragments) { - const frag = fragment as { pmStart?: number; pmEnd?: number }; - if (frag.pmStart != null && frag.pmEnd != null) { - if (pos >= frag.pmStart && pos <= frag.pmEnd) { - return pageIdx; - } - } - } - } - - return 0; + return this.#findPageIndexForPosition(layout, selection.from) ?? 0; } #findRegionForPage(kind: 'header' | 'footer', pageIndex: number): HeaderFooterRegion | null { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 698f155d34..420ba99477 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -1614,6 +1614,10 @@ export class HeaderFooterSessionManager { let sectionRId: string | undefined; if (page?.sectionRefs && kind === 'header') { sectionRId = page.sectionRefs.headerRefs?.[headerFooterType as keyof typeof page.sectionRefs.headerRefs]; + if (!sectionRId && headerFooterType && multiSectionId) { + const row = multiSectionId.sectionHeaderIds.get(sectionIndex); + sectionRId = row?.[headerFooterType] ?? multiSectionId.headerIds[headerFooterType] ?? undefined; + } if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { const prevSectionIds = multiSectionId.sectionHeaderIds.get(sectionIndex - 1); sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; @@ -1623,6 +1627,10 @@ export class HeaderFooterSessionManager { } } else if (page?.sectionRefs && kind === 'footer') { sectionRId = page.sectionRefs.footerRefs?.[headerFooterType as keyof typeof page.sectionRefs.footerRefs]; + if (!sectionRId && headerFooterType && multiSectionId) { + const row = multiSectionId.sectionFooterIds.get(sectionIndex); + sectionRId = row?.[headerFooterType] ?? multiSectionId.footerIds[headerFooterType] ?? undefined; + } if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { const prevSectionIds = multiSectionId.sectionFooterIds.get(sectionIndex - 1); sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index a9e90d5b1f..4e2d1f61c2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -39,7 +39,7 @@ import { } from '../tables/TableSelectionUtilities.js'; import { debugLog } from '../selection/SelectionDebug.js'; import { DOM_CLASS_NAMES, buildAnnotationSelector, DRAGGABLE_SELECTOR } from '@superdoc/dom-contract'; -import { isSemanticFootnoteBlockId } from '../semantic-flow-constants.js'; +import { isFootnoteLayoutBlockId } from '../semantic-flow-constants.js'; import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; // ============================================================================= @@ -70,15 +70,6 @@ type CommentThreadHit = { threadId: string | null; }; -/** - * Block IDs for footnote content use prefix "footnote-{id}-" (see FootnotesBuilder). - * Semantic footnote blocks use the {@link isSemanticFootnoteBlockId} helper from - * shared constants — it matches both heading and body footnote block IDs. - */ -function isFootnoteBlockId(blockId: string): boolean { - return typeof blockId === 'string' && (blockId.startsWith('footnote-') || isSemanticFootnoteBlockId(blockId)); -} - function getCommentHighlightThreadIds(target: EventTarget | null): string[] { if (!(target instanceof Element)) { return []; @@ -1074,7 +1065,7 @@ export class EditorInputManager { // Disallow cursor placement in footnote lines: keep current selection and only focus editor. const fragmentEl = target?.closest?.('[data-block-id]') as HTMLElement | null; const clickedBlockId = fragmentEl?.getAttribute?.('data-block-id') ?? ''; - if (isFootnoteBlockId(clickedBlockId)) { + if (isFootnoteLayoutBlockId(clickedBlockId)) { if (!isDraggableAnnotation) event.preventDefault(); this.#focusEditor(); return; @@ -1182,7 +1173,7 @@ export class EditorInputManager { // Disallow cursor placement in footnote lines (footnote content is read-only in the layout). // Keep the current selection unchanged instead of moving caret to document start. - if (isFootnoteBlockId(rawHit.blockId)) { + if (isFootnoteLayoutBlockId(rawHit.blockId)) { this.#focusEditor(); return; } @@ -1918,7 +1909,7 @@ export class EditorInputManager { if (!rawHit) return; // Don't extend selection into footnote lines - if (isFootnoteBlockId(rawHit.blockId)) return; + if (isFootnoteLayoutBlockId(rawHit.blockId)) return; const editor = this.#deps.getEditor(); const doc = editor.state?.doc; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/semantic-flow-constants.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/semantic-flow-constants.ts index 60b14f0ecd..6199d22ebf 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/semantic-flow-constants.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/semantic-flow-constants.ts @@ -12,3 +12,14 @@ export const SEMANTIC_FOOTNOTE_BLOCK_ID_PREFIX = '__sd_semantic_footnote'; export function isSemanticFootnoteBlockId(blockId: string): boolean { return typeof blockId === 'string' && blockId.startsWith(SEMANTIC_FOOTNOTE_BLOCK_ID_PREFIX); } + +/** + * True when a layout / painted `data-block-id` belongs to the footnote band + * (DOCX `footnote-*` fragments from FootnotesBuilder, semantic-flow `__sd_semantic_footnote*` + * bodies — heading included via that prefix — and separators). + * Use for hit-testing, DomPositionIndex (`isFootnotePaintedBlockHost`), and layout fragment scans. + */ +export function isFootnoteLayoutBlockId(blockId: string | null | undefined): boolean { + if (typeof blockId !== 'string' || blockId.length === 0) return false; + return blockId.startsWith('footnote-') || isSemanticFootnoteBlockId(blockId); +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts index 13ffa0ac7c..58ba6a4f48 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomPositionIndex.test.ts @@ -65,6 +65,41 @@ describe('DomPositionIndex', () => { expect(index.findElementAtPosition(1)?.textContent).toBe('body'); }); + it('skips footnote painted fragments when building the index', () => { + const container = document.createElement('div'); + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = '0'; + + const bodyLine = document.createElement('div'); + bodyLine.className = 'superdoc-line'; + const bodySpan = document.createElement('span'); + bodySpan.setAttribute('data-pm-start', '1'); + bodySpan.setAttribute('data-pm-end', '5'); + bodySpan.textContent = 'body'; + bodyLine.appendChild(bodySpan); + page.appendChild(bodyLine); + + const fnBlock = document.createElement('div'); + fnBlock.className = 'superdoc-line'; + fnBlock.setAttribute('data-block-id', 'footnote-1-0-paragraph'); + const fnSpan = document.createElement('span'); + fnSpan.setAttribute('data-pm-start', '100'); + fnSpan.setAttribute('data-pm-end', '120'); + fnSpan.textContent = 'fn text'; + fnBlock.appendChild(fnSpan); + page.appendChild(fnBlock); + + container.appendChild(page); + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.size).toBe(1); + expect(index.findElementAtPosition(3)?.textContent).toBe('body'); + expect(index.findEntryClosestToPosition(50)?.el.textContent).toBe('body'); + }); + it('skips footer-only content when building the index', () => { const container = document.createElement('div'); container.innerHTML = ` diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/semantic-flow-footnotes.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/semantic-flow-footnotes.test.ts index 2402659595..2e6f8d7eb6 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/semantic-flow-footnotes.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/semantic-flow-footnotes.test.ts @@ -3,6 +3,7 @@ import type { FlowBlock } from '@superdoc/contracts'; import { buildSemanticFootnoteBlocks } from '../semantic-flow-footnotes.js'; import { + isFootnoteLayoutBlockId, isSemanticFootnoteBlockId, SEMANTIC_FOOTNOTES_HEADING_BLOCK_ID, SEMANTIC_FOOTNOTE_BLOCK_ID_PREFIX, @@ -64,4 +65,13 @@ describe('semantic-flow-footnotes', () => { expect(isSemanticFootnoteBlockId(`${SEMANTIC_FOOTNOTE_BLOCK_ID_PREFIX}-1-0-0-fn-1`)).toBe(true); expect(isSemanticFootnoteBlockId('footnote-1-0')).toBe(false); }); + + it('isFootnoteLayoutBlockId matches DOCX and semantic painted footnote block ids', () => { + expect(isFootnoteLayoutBlockId('footnote-1-0-paragraph')).toBe(true); + expect(isFootnoteLayoutBlockId(SEMANTIC_FOOTNOTES_HEADING_BLOCK_ID)).toBe(true); + expect(isFootnoteLayoutBlockId(`${SEMANTIC_FOOTNOTE_BLOCK_ID_PREFIX}-1-0-0-fn-1`)).toBe(true); + expect(isFootnoteLayoutBlockId('body-1')).toBe(false); + expect(isFootnoteLayoutBlockId(null)).toBe(false); + expect(isFootnoteLayoutBlockId('')).toBe(false); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index 0328fcb44c..1067d23f04 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -122,6 +122,8 @@ export type ResolvedLayoutOptions = margins: ResolvedMarginsBase; columns?: { count: number; gap: number }; sectionMetadata: SectionMetadata[]; + /** Document-level w:evenAndOddHeaders — used by layout pagination for per-variant margins */ + oddEvenHeadersFooters?: boolean; } | { flowMode: 'semantic'; @@ -136,6 +138,7 @@ export type ResolvedLayoutOptions = marginBottom: number; }; sectionMetadata: SectionMetadata[]; + oddEvenHeadersFooters?: boolean; }; export type LayoutEngineOptions = { diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts index b555ff3a4b..1fcbb66169 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomPositionIndex.ts @@ -1,7 +1,20 @@ import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; import { sortedIndexBy } from 'lodash'; +import { isFootnoteLayoutBlockId } from '../core/presentation-editor/semantic-flow-constants.js'; import { debugLog, getSelectionDebugConfig } from '../core/presentation-editor/selection/SelectionDebug.js'; +/** + * True when the PM-range element sits under a painted footnote/separator fragment. + * Those nodes must not participate in body caret/DOM-index resolution — they use + * different PM ranges and resolving "closest" gaps to them jumps the caret/scroll + * to the footnote band (notably with footnotes + multi-pass layout). + */ +export function isFootnotePaintedBlockHost(node: HTMLElement): boolean { + const host = node.closest('[data-block-id]'); + if (!(host instanceof HTMLElement)) return false; + return isFootnoteLayoutBlockId(host.dataset.blockId); +} + /** * Represents a single entry in the DOM position index. * @@ -99,6 +112,7 @@ export class DomPositionIndex { for (const node of pmNodes) { if (node.classList.contains(DOM_CLASS_NAMES.INLINE_SDT_WRAPPER)) continue; if (node.closest('.superdoc-page-header, .superdoc-page-footer')) continue; + if (isFootnotePaintedBlockHost(node)) continue; if (leafOnly && nonLeaf.has(node)) continue; const pmStart = Number(node.dataset.pmStart ?? 'NaN');