diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 77bd061260..362fd97d0c 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -849,6 +849,17 @@ export type SectionMetadata = { titlePg?: boolean; /** Vertical alignment of content within this section's pages */ vAlign?: SectionVerticalAlign; + /** Section page margins in CSS px */ + margins?: { + top?: number; + right?: number; + bottom?: number; + left?: number; + header?: number; + footer?: number; + } | null; + /** Section page size in CSS px */ + pageSize?: { w: number; h: number } | null; }; export type PageBreakBlock = { @@ -1581,6 +1592,8 @@ export type TableFragment = { continuesOnNext?: boolean; repeatHeaderCount?: number; partialRow?: PartialRowInfo; + /** Rescaled column widths when table is clamped to a narrower section (SD-1859). */ + columnWidths?: number[]; metadata?: TableFragmentMetadata; pmStart?: number; pmEnd?: number; diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 82433951d4..f97e4ac075 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -35,6 +35,8 @@ export type HeaderFooterLayoutResult = { layout: HeaderFooterLayout; blocks: FlowBlock[]; measures: Measure[]; + /** Effective layout width when table grid widths exceed section content width (SD-1837). */ + effectiveWidth?: number; }; export type IncrementalLayoutResult = { diff --git a/packages/layout-engine/layout-engine/src/layout-table.test.ts b/packages/layout-engine/layout-engine/src/layout-table.test.ts index 9a286b75c2..a348bf4ad8 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -3167,4 +3167,117 @@ describe('layoutTableBlock', () => { } }); }); + + describe('column width rescaling (SD-1859)', () => { + it('should rescale column widths when table is wider than section content width', () => { + // Simulate a table measured at landscape width (700px) but rendered in + // a portrait section (450px). Column widths should be rescaled to fit. + const block = createMockTableBlock(2); + const measure = createMockTableMeasure([250, 200, 250], [30, 30]); + // measure.totalWidth = 700 + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 450, // Portrait section width (narrower than table) + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 0, + }); + + expect(fragments).toHaveLength(1); + const fragment = fragments[0]; + + // Fragment width should be clamped to section width + expect(fragment.width).toBe(450); + + // Column widths should be rescaled proportionally + expect(fragment.columnWidths).toBeDefined(); + expect(fragment.columnWidths!.length).toBe(3); + + // Sum of rescaled column widths should equal fragment width + const sum = fragment.columnWidths!.reduce((a, b) => a + b, 0); + expect(sum).toBe(450); + + // Proportions should be maintained (250:200:250 → ~161:129:161) + expect(fragment.columnWidths![0]).toBeGreaterThan(fragment.columnWidths![1]); + expect(fragment.columnWidths![0]).toBeCloseTo(fragment.columnWidths![2], -1); + }); + + it('should set original columnWidths when table fits within section width (SD-1837)', () => { + const block = createMockTableBlock(2); + const measure = createMockTableMeasure([100, 150, 100], [30, 30]); + // measure.totalWidth = 350 + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 450, // Section is wider than table + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 0, + }); + + expect(fragments).toHaveLength(1); + // columnWidths should always be set for self-contained fragments (SD-1837) + expect(fragments[0].columnWidths).toEqual([100, 150, 100]); + }); + + it('should rescale column widths on paginated table fragments', () => { + // Table that splits across pages should have rescaled column widths on each fragment + const block = createMockTableBlock(4); + const measure = createMockTableMeasure([300, 300], [200, 200, 200, 200]); + // totalWidth = 600, each row = 200px + + const fragments: TableFragment[] = []; + let pageIndex = 0; + + layoutTableBlock({ + block, + measure, + columnWidth: 400, // Narrower than table + ensurePage: () => ({ + page: { fragments }, + columnIndex: 0, + cursorY: 0, + contentBottom: 500, // Only fits ~2 rows per page + }), + advanceColumn: (state) => { + pageIndex++; + return { + ...state, + cursorY: 0, + contentBottom: 500, + }; + }, + columnX: () => 0, + }); + + // Should have multiple fragments (table paginated) + expect(fragments.length).toBeGreaterThanOrEqual(1); + + // Every fragment should have rescaled column widths + for (const fragment of fragments) { + expect(fragment.columnWidths).toBeDefined(); + const sum = fragment.columnWidths!.reduce((a, b) => a + b, 0); + expect(sum).toBe(400); + } + }); + }); }); diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index b02c5d9fbe..b5d928aebd 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -174,6 +174,41 @@ function resolveTableFrame( return applyTableIndent(baseX, width, tableIndent); } +/** + * Rescales column widths when a table is clamped to fit a narrower section. + * + * In mixed-orientation documents, tables are measured at the widest section's + * content width but may render in narrower sections. When the measured total + * width exceeds the fragment width, column widths must be proportionally + * rescaled so cells don't overflow the fragment container (SD-1859). + * + * @returns Rescaled column widths if clamping occurred, undefined otherwise. + */ +function rescaleColumnWidths( + measureColumnWidths: number[] | undefined, + measureTotalWidth: number, + fragmentWidth: number, +): number[] | undefined { + if (!measureColumnWidths || measureColumnWidths.length === 0) { + return undefined; + } + // When the table fits within the fragment, return original widths unchanged. + // This ensures every table fragment is self-contained with its own column widths, + // which is critical for header/footer tables where multiple sections share the same + // blockId but have different content widths (SD-1837). + if (measureTotalWidth <= fragmentWidth || measureTotalWidth <= 0) { + return measureColumnWidths; + } + const scale = fragmentWidth / measureTotalWidth; + const scaled = measureColumnWidths.map((w) => Math.max(1, Math.round(w * scale))); + const scaledSum = scaled.reduce((a, b) => a + b, 0); + const target = Math.round(fragmentWidth); + if (scaledSum !== target && scaled.length > 0) { + scaled[scaled.length - 1] = Math.max(1, scaled[scaled.length - 1] + (target - scaledSum)); + } + return scaled; +} + /** * Calculate minimum width for a table column. * @@ -986,6 +1021,7 @@ function layoutMonolithicTable(context: TableLayoutContext): void { y: state.cursorY, width, height, + columnWidths: rescaleColumnWidths(context.measure.columnWidths, context.measure.totalWidth, width), metadata, }; applyTableFragmentPmRange(fragment, context.block, context.measure); @@ -1133,6 +1169,7 @@ export function layoutTableBlock({ y: state.cursorY, width, height, + columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width), metadata, }; applyTableFragmentPmRange(fragment, block, measure); @@ -1219,6 +1256,7 @@ export function layoutTableBlock({ continuesOnNext: hasRemainingLinesAfterContinuation || rowIndex + 1 < block.rows.length, repeatHeaderCount, partialRow: continuationPartialRow, + columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width), metadata: generateFragmentMetadata(measure, rowIndex, rowIndex + 1, repeatHeaderCount), }; @@ -1282,6 +1320,7 @@ export function layoutTableBlock({ continuesOnNext: !forcedPartialRow.isLastPart || forcedEndRow < block.rows.length, repeatHeaderCount, partialRow: forcedPartialRow, + columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width), metadata: generateFragmentMetadata(measure, bodyStartRow, forcedEndRow, repeatHeaderCount), }; @@ -1323,6 +1362,7 @@ export function layoutTableBlock({ continuesOnNext: endRow < block.rows.length || (partialRow ? !partialRow.isLastPart : false), repeatHeaderCount, partialRow: partialRow || undefined, + columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width), metadata: generateFragmentMetadata(measure, bodyStartRow, endRow, repeatHeaderCount), }; diff --git a/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts b/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts index 0abe5dda82..37c60496b5 100644 --- a/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts +++ b/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts @@ -846,6 +846,8 @@ describe('analysis', () => { footerRefs: { default: 'footer1' }, numbering: { format: 'decimal' }, titlePg: false, + margins: null, + pageSize: null, }); }); diff --git a/packages/layout-engine/pm-adapter/src/sections/analysis.ts b/packages/layout-engine/pm-adapter/src/sections/analysis.ts index 879a5ac9b2..a6956cd685 100644 --- a/packages/layout-engine/pm-adapter/src/sections/analysis.ts +++ b/packages/layout-engine/pm-adapter/src/sections/analysis.ts @@ -204,6 +204,8 @@ export function publishSectionMetadata(sectionRanges: SectionRange[], options?: numbering: section.numbering, titlePg: section.titlePg, vAlign: section.vAlign, + margins: section.margins, + pageSize: section.pageSize, }); }); } diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index af738f2d72..bdfdda9bc0 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -159,6 +159,18 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* width: 100%; */ } +/* Header/footer editors: constrain table to the editor's available width. + Raw DOCX grid widths may exceed the section's content area. + table-layout:auto treats col widths as preferred (not hard) constraints, + so the browser can redistribute columns to fit within width:100%. + !important overrides inline styles set by TableView.updateTable() + which resets styles via table.style.cssText. */ +.ProseMirror.sd-header-footer table { + table-layout: auto !important; + width: 100% !important; + max-width: 100% !important; +} + .ProseMirror tr { position: relative; } diff --git a/packages/super-editor/src/core/header-footer/EditorOverlayManager.ts b/packages/super-editor/src/core/header-footer/EditorOverlayManager.ts index 1c940daa2d..acf2fa5701 100644 --- a/packages/super-editor/src/core/header-footer/EditorOverlayManager.ts +++ b/packages/super-editor/src/core/header-footer/EditorOverlayManager.ts @@ -390,7 +390,7 @@ export class EditorOverlayManager { position: 'absolute', pointerEvents: 'auto', // Critical: enables click interaction visibility: 'hidden', // Hidden by default, shown during editing - overflow: 'hidden', + overflow: 'visible', // Allow table overflow (page's overflow:hidden still clips at page edge) boxSizing: 'border-box', }); diff --git a/packages/super-editor/src/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/core/header-footer/HeaderFooterPerRidLayout.ts index a3a0c1af76..1a24ad654e 100644 --- a/packages/super-editor/src/core/header-footer/HeaderFooterPerRidLayout.ts +++ b/packages/super-editor/src/core/header-footer/HeaderFooterPerRidLayout.ts @@ -1,4 +1,5 @@ -import type { FlowBlock, Layout, SectionMetadata } from '@superdoc/contracts'; +import type { FlowBlock, HeaderFooterLayout, Layout, SectionMetadata } from '@superdoc/contracts'; +import { OOXML_PCT_DIVISOR } from '@superdoc/contracts'; import { computeDisplayPageNumber, layoutHeaderFooterWithCache } from '@superdoc/layout-bridge'; import type { HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; import { measureBlock } from '@superdoc/measuring-dom'; @@ -11,6 +12,146 @@ export type HeaderFooterPerRidLayoutInput = { constraints: { width: number; height: number; pageWidth: number; margins: { left: number; right: number } }; }; +type Constraints = HeaderFooterPerRidLayoutInput['constraints']; + +/** + * Compute the content width for a section, falling back to global constraints. + */ +function buildSectionContentWidth(section: SectionMetadata, fallback: Constraints): number { + const pageW = section.pageSize?.w ?? fallback.pageWidth; + const marginL = section.margins?.left ?? fallback.margins.left; + const marginR = section.margins?.right ?? fallback.margins.right; + return pageW - marginL - marginR; +} + +/** + * Build constraints for a section using its margins/pageSize, falling back to global. + * When a table's grid width exceeds the content width, use the grid width instead (SD-1837). + * Word allows auto-width tables in headers/footers to extend beyond the body margins. + */ +function buildConstraintsForSection(section: SectionMetadata, fallback: Constraints, minWidth?: number): Constraints { + const pageW = section.pageSize?.w ?? fallback.pageWidth; + const marginL = section.margins?.left ?? fallback.margins.left; + const marginR = section.margins?.right ?? fallback.margins.right; + const contentWidth = pageW - marginL - marginR; + // Allow tables to extend beyond right margin when grid width > content width. + // Capped at pageWidth - marginLeft to avoid going past the page edge. + const maxWidth = pageW - marginL; + const effectiveWidth = minWidth ? Math.min(Math.max(contentWidth, minWidth), maxWidth) : contentWidth; + return { + width: effectiveWidth, + height: fallback.height, + pageWidth: pageW, + margins: { left: marginL, right: marginR }, + }; +} + +/** + * Table width specification extracted from footer/header blocks. + * Used to compute the minimum constraint width per section. + */ +type TableWidthSpec = { + /** 'pct' for percentage-based, 'grid' for auto-width using grid columns, 'px' for fixed pixel */ + type: 'pct' | 'grid' | 'px'; + /** For 'pct': OOXML percentage value (e.g. 5161 = 103.22%). For 'grid'/'px': width in pixels. */ + value: number; +}; + +/** + * Extract table width specifications from a set of blocks. + * Returns the spec for the widest table, distinguishing percentage-based from auto/fixed. + * + * For percentage tables (tblW type="pct"), the width must be resolved per-section since it + * depends on the section's content width. The measuring-dom clamps pct tables to the constraint + * width, so we must pre-expand the constraint to contentWidth * pct/5000. + * + * For auto-width tables (no tblW or tblW type="auto"), the grid columns are the layout basis. + */ +function getTableWidthSpec(blocks: FlowBlock[]): TableWidthSpec | undefined { + let result: TableWidthSpec | undefined; + let maxResolvedWidth = 0; + + for (const block of blocks) { + if (block.kind !== 'table') continue; + + const tableWidth = (block as { attrs?: { tableWidth?: { width?: number; value?: number; type?: string } } }).attrs + ?.tableWidth; + const widthValue = tableWidth?.width ?? tableWidth?.value; + + if (tableWidth?.type === 'pct' && typeof widthValue === 'number' && widthValue > 0) { + // Percentage-based table: store the raw pct value for per-section resolution. + // Use a nominal large value for comparison so pct tables take priority. + if (!result || result.type !== 'pct' || widthValue > result.value) { + result = { type: 'pct', value: widthValue }; + maxResolvedWidth = Infinity; // pct always takes priority + } + } else if ((tableWidth?.type === 'px' || tableWidth?.type === 'pixel') && typeof widthValue === 'number') { + // Fixed pixel width + if (widthValue > maxResolvedWidth) { + maxResolvedWidth = widthValue; + result = { type: 'px', value: widthValue }; + } + } else if (block.columnWidths && block.columnWidths.length > 0) { + // Auto-width: use grid columns as minimum width + const gridTotal = block.columnWidths.reduce((sum, w) => sum + w, 0); + if (gridTotal > maxResolvedWidth) { + maxResolvedWidth = gridTotal; + result = { type: 'grid', value: gridTotal }; + } + } + } + + return result; +} + +/** + * Resolve the minimum constraint width for a section based on its table width spec. + * For percentage-based tables, computes the percentage of the section's content width. + * For auto/grid tables, returns the grid total directly. + * + * The measuring-dom clamps pct tables to Math.min(resolvedWidth, maxWidth), so for + * pct > 100% the table would be limited to the constraint. We pre-compute the resolved + * pct width and use it as the minimum constraint so the table can overflow properly. + */ +function resolveTableMinWidth(spec: TableWidthSpec | undefined, contentWidth: number): number { + if (!spec) return 0; + if (spec.type === 'pct') { + return contentWidth * (spec.value / OOXML_PCT_DIVISOR); + } + return spec.value; // grid or px: already in pixels +} + +/** + * Resolve the rId for each section, inheriting from previous sections when not explicitly set. + * This follows Word's OOXML inheritance model: if a section has no ref for a given kind, + * it inherits the previous section's ref. + */ +function resolveRIdPerSection(sectionMetadata: SectionMetadata[], kind: 'header' | 'footer'): Map { + const result = new Map(); + let inherited: string | undefined; + + for (const section of sectionMetadata) { + const refs = kind === 'header' ? section.headerRefs : section.footerRefs; + const rId = refs?.default; + if (rId) { + inherited = rId; + } + if (inherited) { + result.set(section.sectionIndex, inherited); + } + } + + return result; +} + +/** + * Layout header/footer blocks per rId, respecting per-section margins. + * + * For documents with multiple sections that have different margins, this function + * measures the same header/footer content at different widths and stores results + * with composite keys (`${rId}::s${sectionIndex}`) so each page gets the correctly + * sized layout. + */ export async function layoutPerRIdHeaderFooters( headerFooterInput: HeaderFooterPerRidLayoutInput | null, layout: Layout, @@ -39,61 +180,239 @@ export async function layoutPerRIdHeaderFooters( }; }; - if (headerBlocksByRId) { - for (const [rId, blocks] of headerBlocksByRId) { - if (!blocks || blocks.length === 0) continue; - - try { - const batchResult = await layoutHeaderFooterWithCache( - { default: blocks }, - constraints, - (block: FlowBlock, c: { maxWidth: number; maxHeight: number }) => measureBlock(block, c), - undefined, - undefined, - pageResolver, - ); - - if (batchResult.default) { - deps.headerLayoutsByRId.set(rId, { - kind: 'header', - type: 'default', - layout: batchResult.default.layout, - blocks: batchResult.default.blocks, - measures: batchResult.default.measures, - }); - } - } catch (error) { - console.warn(`[PresentationEditor] Failed to layout header rId=${rId}:`, error); + const hasPerSectionMargins = sectionMetadata.length > 1 && sectionMetadata.some((s) => s.margins || s.pageSize); + + if (hasPerSectionMargins) { + await layoutWithPerSectionConstraints( + 'header', + headerBlocksByRId, + sectionMetadata, + constraints, + pageResolver, + deps.headerLayoutsByRId, + ); + await layoutWithPerSectionConstraints( + 'footer', + footerBlocksByRId, + sectionMetadata, + constraints, + pageResolver, + deps.footerLayoutsByRId, + ); + } else { + // Single-section or uniform margins: use original single-constraint path + await layoutBlocksByRId('header', headerBlocksByRId, constraints, pageResolver, deps.headerLayoutsByRId); + await layoutBlocksByRId('footer', footerBlocksByRId, constraints, pageResolver, deps.footerLayoutsByRId); + } +} + +/** + * Layout blocks for a given kind (header/footer) using a single set of constraints. + * This is the original code path for single-section or uniform-margin documents. + */ +async function layoutBlocksByRId( + kind: 'header' | 'footer', + blocksByRId: Map | undefined, + constraints: Constraints, + pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, + layoutsByRId: Map, +): Promise { + if (!blocksByRId) return; + + for (const [rId, blocks] of blocksByRId) { + if (!blocks || blocks.length === 0) continue; + + try { + const batchResult = await layoutHeaderFooterWithCache( + { default: blocks }, + constraints, + (block: FlowBlock, c: { maxWidth: number; maxHeight: number }) => measureBlock(block, c), + undefined, + undefined, + pageResolver, + ); + + if (batchResult.default) { + layoutsByRId.set(rId, { + kind, + type: 'default', + layout: batchResult.default.layout, + blocks: batchResult.default.blocks, + measures: batchResult.default.measures, + }); } + } catch (error) { + console.warn(`[PresentationEditor] Failed to layout ${kind} rId=${rId}:`, error); } } +} + +/** + * Deep-clone a HeaderFooterLayout so we can adjust fragment positions per-section + * without mutating the shared measurement result. + */ +function cloneHeaderFooterLayout(layout: HeaderFooterLayout): HeaderFooterLayout { + return { + ...layout, + pages: layout.pages.map((page) => ({ + ...page, + fragments: page.fragments.map((f) => ({ ...f })), + })), + }; +} + +/** + * Adjust frame-positioned paragraph fragments to use the section's content width + * instead of the effective (table-extended) width for horizontal positioning. + * + * In Word, frame paragraphs with hAnchor="margin" are positioned relative to + * the section's content margins, not the overflowed table width (SD-1837). + */ +function adjustFramePositionsForContentWidth( + layout: HeaderFooterLayout, + blocks: FlowBlock[], + effectiveWidth: number, + contentWidth: number, +): void { + if (effectiveWidth <= contentWidth) return; + + const widthDiff = effectiveWidth - contentWidth; + + // Build block lookup by id + const blockById = new Map(); + for (const block of blocks) { + blockById.set(block.id, block); + } + + for (const page of layout.pages) { + for (const fragment of page.fragments) { + if (fragment.kind !== 'para') continue; + + const block = blockById.get(fragment.blockId); + if (!block || block.kind !== 'paragraph') continue; + + const frame = block.attrs?.frame; + if (!frame || frame.wrap !== 'none') continue; + + if (frame.xAlign === 'right') { + fragment.x -= widthDiff; + } else if (frame.xAlign === 'center') { + fragment.x -= widthDiff / 2; + } + } + } +} - if (footerBlocksByRId) { - for (const [rId, blocks] of footerBlocksByRId) { - if (!blocks || blocks.length === 0) continue; - - try { - const batchResult = await layoutHeaderFooterWithCache( - { default: blocks }, - constraints, - (block: FlowBlock, c: { maxWidth: number; maxHeight: number }) => measureBlock(block, c), - undefined, - undefined, - pageResolver, - ); - - if (batchResult.default) { - deps.footerLayoutsByRId.set(rId, { - kind: 'footer', +/** + * Layout blocks with per-section constraints. Groups sections by (rId, contentWidth) + * to avoid redundant measurements, and stores results with composite keys. + */ +async function layoutWithPerSectionConstraints( + kind: 'header' | 'footer', + blocksByRId: Map | undefined, + sectionMetadata: SectionMetadata[], + fallbackConstraints: Constraints, + pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, + layoutsByRId: Map, +): Promise { + if (!blocksByRId) return; + + const rIdPerSection = resolveRIdPerSection(sectionMetadata, kind); + + // Extract table width specs per rId (SD-1837). + // Word allows tables in headers/footers to extend beyond content margins. + // For pct tables, the width is relative to the section's content width. + // For auto-width tables, the grid columns define the minimum width. + const tableWidthSpecByRId = new Map(); + for (const [rId, blocks] of blocksByRId) { + const spec = getTableWidthSpec(blocks); + if (spec) { + tableWidthSpecByRId.set(rId, spec); + } + } + + // Group sections by (rId, effectiveWidth) to measure each unique pair only once + // Key: `${rId}::w${effectiveWidth}`, Value: { constraints, sections[] } + const groups = new Map< + string, + { sectionConstraints: Constraints; sectionIndices: number[]; rId: string; effectiveWidth: number } + >(); + + for (const section of sectionMetadata) { + const rId = rIdPerSection.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; + const groupKey = `${rId}::w${effectiveWidth}`; + + let group = groups.get(groupKey); + if (!group) { + group = { + sectionConstraints, + sectionIndices: [], + rId, + effectiveWidth, + }; + groups.set(groupKey, group); + } + group.sectionIndices.push(section.sectionIndex); + } + + // Measure and layout each unique (rId, effectiveWidth) group + for (const [, group] of groups) { + const blocks = blocksByRId.get(group.rId); + if (!blocks || blocks.length === 0) continue; + + try { + const batchResult = await layoutHeaderFooterWithCache( + { default: blocks }, + group.sectionConstraints, + (block: FlowBlock, c: { maxWidth: number; maxHeight: number }) => measureBlock(block, c), + undefined, + undefined, + pageResolver, + ); + + if (batchResult.default) { + // Store a result per section. Sections in the same group share the same + // measured layout, but may need different frame position adjustments + // because they have different content widths (SD-1837). + for (const sectionIndex of group.sectionIndices) { + const section = sectionMetadata.find((s) => s.sectionIndex === sectionIndex)!; + const contentWidth = buildSectionContentWidth(section, fallbackConstraints); + const needsFrameAdjust = group.effectiveWidth > contentWidth; + + // Frame-positioned paragraphs (e.g. page numbers with framePr hAnchor="margin") + // must be positioned relative to the section's content width, not the effective + // (table-extended) width. Word positions these frames within the margin area + // independently of any table overflow. Clone the layout when adjusting to avoid + // mutating the shared result. + let layout = batchResult.default.layout; + if (needsFrameAdjust) { + layout = cloneHeaderFooterLayout(layout); + adjustFramePositionsForContentWidth(layout, batchResult.default.blocks, group.effectiveWidth, contentWidth); + } + + const result: HeaderFooterLayoutResult = { + kind, type: 'default', - layout: batchResult.default.layout, + layout, blocks: batchResult.default.blocks, measures: batchResult.default.measures, - }); + effectiveWidth: needsFrameAdjust ? group.effectiveWidth : undefined, + }; + + layoutsByRId.set(`${group.rId}::s${sectionIndex}`, result); } - } catch (error) { - console.warn(`[PresentationEditor] Failed to layout footer rId=${rId}:`, error); } + } catch (error) { + console.warn(`[PresentationEditor] Failed to layout ${kind} rId=${group.rId}:`, error); } } } diff --git a/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts index e939d16344..4d178636d1 100644 --- a/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts +++ b/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts @@ -350,6 +350,10 @@ export class HeaderFooterEditorManager extends EventEmitter { } if (Object.keys(updateOptions).length > 0) { existing.editor.setOptions(updateOptions); + // Refresh page number display after option changes. + // NodeViews read editor.options but PM doesn't re-render them + // when only options change (no document transaction). + this.#refreshPageNumberDisplay(existing.editor); } } @@ -395,6 +399,31 @@ export class HeaderFooterEditorManager extends EventEmitter { return creationPromise; } + /** + * Updates page number DOM elements to reflect current editor options. + * Called after setOptions to sync NodeViews that read editor.options. + */ + #refreshPageNumberDisplay(editor: Editor): void { + const container = editor.view?.dom; + if (!container) return; + + const opts = editor.options as Record; + const parentEditor = opts.parentEditor as Record | undefined; + + const currentPage = String(opts.currentPageNumber || '1'); + const totalPages = String(opts.totalPageCount || parentEditor?.currentTotalPages || '1'); + + const pageNumberEls = container.querySelectorAll('[data-id="auto-page-number"]'); + const totalPagesEls = container.querySelectorAll('[data-id="auto-total-pages"]'); + + pageNumberEls.forEach((el) => { + if (el.textContent !== currentPage) el.textContent = currentPage; + }); + totalPagesEls.forEach((el) => { + if (el.textContent !== totalPages) el.textContent = totalPages; + }); + } + /** * Retrieves the editor instance for a given header/footer descriptor, * if one has been created. diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 015ef89d25..4b40fb8548 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2440,7 +2440,8 @@ export class PresentationEditor extends EventEmitter { goToAnchor: (href: string) => this.goToAnchor(href), emit: (event: string, payload: unknown) => this.emit(event, payload), normalizeClientPoint: (clientX: number, clientY: number) => this.#normalizeClientPoint(clientX, clientY), - hitTestHeaderFooterRegion: (x: number, y: number) => this.#hitTestHeaderFooterRegion(x, y), + hitTestHeaderFooterRegion: (x: number, y: number, pageIndex?: number, pageLocalY?: number) => + this.#hitTestHeaderFooterRegion(x, y, pageIndex, pageLocalY), exitHeaderFooterMode: () => this.#exitHeaderFooterMode(), activateHeaderFooterRegion: (region) => this.#activateHeaderFooterRegion(region), createDefaultHeaderFooter: (region) => this.#createDefaultHeaderFooter(region), @@ -2629,6 +2630,7 @@ export class PresentationEditor extends EventEmitter { setPendingDocChange: () => { this.#pendingDocChange = true; }, + getBodyPageCount: () => this.#layoutState?.layout?.pages?.length ?? 1, }); // Set up callbacks @@ -3758,8 +3760,8 @@ export class PresentationEditor extends EventEmitter { * Hit test for header/footer regions at a given point. * Delegates to HeaderFooterSessionManager which manages region tracking. */ - #hitTestHeaderFooterRegion(x: number, y: number): HeaderFooterRegion | null { - return this.#headerFooterSession?.hitTestRegion(x, y, this.#layoutState.layout) ?? null; + #hitTestHeaderFooterRegion(x: number, y: number, pageIndex?: number, pageLocalY?: number): HeaderFooterRegion | null { + return this.#headerFooterSession?.hitTestRegion(x, y, this.#layoutState.layout, pageIndex, pageLocalY) ?? null; } #activateHeaderFooterRegion(region: HeaderFooterRegion) { @@ -4518,7 +4520,7 @@ export class PresentationEditor extends EventEmitter { ); } - #normalizeClientPoint(clientX: number, clientY: number): { x: number; y: number } | null { + #normalizeClientPoint(clientX: number, clientY: number): { x: number; y: number; pageIndex?: number } | null { return normalizeClientPointFromPointer( { viewportHost: this.#viewportHost, diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts index 46d4a75cec..8920957193 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts @@ -60,7 +60,7 @@ describe('PointerNormalization', () => { }; const result = normalizeClientPoint(options, 200, 150); - expect(result).toEqual({ x: 105, y: 90 }); + expect(result).toEqual({ x: 105, y: 90, pageIndex: undefined }); }); it('adjusts X when the pointer is over a page with a known offset', () => { @@ -81,8 +81,12 @@ describe('PointerNormalization', () => { getPageOffsetY: (pageIndex: number) => (pageIndex === 2 ? 8 : null), }; + // X is adjusted by page offset, Y stays as global layout coordinates, + // pageLocalY is computed from the page element's bounding rect const result = normalizeClientPoint(options, 200, 150); - expect(result).toEqual({ x: 93, y: 82 }); + // pageLocalY = (clientY - pageRect.top) / zoom = (150 - 0) / 2 = 75 + // (pageEl is a detached element so getBoundingClientRect returns 0) + expect(result).toEqual({ x: 93, y: 90, pageIndex: 2, pageLocalY: 75 }); }); it('does not adjust X when page offset is unavailable', () => { @@ -104,7 +108,8 @@ describe('PointerNormalization', () => { }; const result = normalizeClientPoint(options, 200, 150); - expect(result).toEqual({ x: 105, y: 90 }); + // pageLocalY is still computed even when X offset is unavailable + expect(result).toEqual({ x: 105, y: 90, pageIndex: 3, pageLocalY: 75 }); }); }); diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts index 52c7c5a23b..4d80607f24 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts @@ -20,7 +20,7 @@ export function normalizeClientPoint( }, clientX: number, clientY: number, -): { x: number; y: number } | null { +): { x: number; y: number; pageIndex?: number; pageLocalY?: number } | null { if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) { return null; } @@ -35,8 +35,11 @@ export function normalizeClientPoint( // Adjust X by the actual page offset if the pointer is over a page. This keeps // geometry-based hit testing aligned with the centered page content. + // Y stays as global layout Y for clickToPosition and other downstream consumers. + // pageLocalY is computed separately for header/footer hit testing. let adjustedX = baseX; - let adjustedY = baseY; + let detectedPageIndex: number | undefined; + let pageLocalY: number | undefined; const doc = options.visibleHost.ownerDocument ?? document; const hitChain = typeof doc.elementsFromPoint === 'function' ? doc.elementsFromPoint(clientX, clientY) : []; const pageEl = Array.isArray(hitChain) @@ -45,20 +48,23 @@ export function normalizeClientPoint( if (pageEl) { const pageIndex = Number(pageEl.dataset.pageIndex ?? 'NaN'); if (Number.isFinite(pageIndex)) { + detectedPageIndex = pageIndex; const pageOffsetX = options.getPageOffsetX(pageIndex); if (pageOffsetX != null) { adjustedX = baseX - pageOffsetX; } - const pageOffsetY = options.getPageOffsetY(pageIndex); - if (pageOffsetY != null) { - adjustedY = baseY - pageOffsetY; - } + // Compute page-local Y directly from the page element's DOM position. + // This is always correct regardless of scroll, virtualization, or mount padding. + const pageRect = pageEl.getBoundingClientRect(); + pageLocalY = (clientY - pageRect.top) / options.zoom; } } return { x: adjustedX, - y: adjustedY, + y: baseY, + pageIndex: detectedPageIndex, + pageLocalY, }; } diff --git a/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 756456a19e..0c284785bd 100644 --- a/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -133,6 +133,8 @@ export type SessionManagerDependencies = { scheduleRerender: () => void; /** Set pending doc change flag */ setPendingDocChange: () => void; + /** Get total page count from body layout */ + getBodyPageCount: () => number; }; /** @@ -469,6 +471,8 @@ export class HeaderFooterSessionManager { // Header region const headerPayload = this.#headerDecorationProvider?.(page.number, margins, page); const headerBox = this.#computeDecorationBox('header', margins, actualPageHeight); + const displayPageNumber = page.numberText ?? String(page.number); + this.#headerRegions.set(pageIndex, { kind: 'header', headerId: headerPayload?.headerId, @@ -476,6 +480,7 @@ export class HeaderFooterSessionManager { headerPayload?.sectionType ?? this.#computeExpectedSectionType('header', page, sectionFirstPageNumbers), pageIndex, pageNumber: page.number, + displayPageNumber, localX: headerPayload?.hitRegion?.x ?? headerBox.x, localY: headerPayload?.hitRegion?.y ?? headerBox.offset, width: headerPayload?.hitRegion?.width ?? headerBox.width, @@ -493,6 +498,7 @@ export class HeaderFooterSessionManager { footerPayload?.sectionType ?? this.#computeExpectedSectionType('footer', page, sectionFirstPageNumbers), pageIndex, pageNumber: page.number, + displayPageNumber, localX: footerPayload?.hitRegion?.x ?? footerBox.x, localY: footerPayload?.hitRegion?.y ?? footerBox.offset, width: footerPayload?.hitRegion?.width ?? footerBox.width, @@ -505,17 +511,43 @@ export class HeaderFooterSessionManager { /** * Hit test for header/footer regions. + * When knownPageIndex is provided (from normalizeClientPoint), use it directly + * since y is already page-local. Otherwise derive pageIndex from global y. */ - hitTestRegion(x: number, y: number, layout: Layout | null): HeaderFooterRegion | null { + hitTestRegion( + x: number, + y: number, + layout: Layout | null, + knownPageIndex?: number, + knownPageLocalY?: number, + ): HeaderFooterRegion | null { if (!layout) return null; const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; - const pageHeight = layout.pageSize?.h ?? layoutOptions.pageSize?.h ?? this.#options.defaultPageSize.h; + const defaultPageHeight = layout.pageSize?.h ?? layoutOptions.pageSize?.h ?? this.#options.defaultPageSize.h; const pageGap = layout.pageGap ?? 0; - if (pageHeight <= 0) return null; - - const pageIndex = Math.max(0, Math.floor(y / (pageHeight + pageGap))); - const pageLocalY = y - pageIndex * (pageHeight + pageGap); + if (defaultPageHeight <= 0) return null; + + let pageIndex: number; + let pageLocalY: number; + + if (knownPageIndex != null && knownPageLocalY != null) { + // Best path: both page index and page-local Y are known from the DOM + pageIndex = knownPageIndex; + pageLocalY = knownPageLocalY; + } else if (knownPageIndex != null) { + // Page index known but no page-local Y — derive from global Y using cumulative heights + pageIndex = knownPageIndex; + let pageTopY = 0; + for (let i = 0; i < pageIndex && i < layout.pages.length; i++) { + pageTopY += (layout.pages[i].size?.h ?? defaultPageHeight) + pageGap; + } + pageLocalY = y - pageTopY; + } else { + // Fallback: derive both from global Y using uniform page height + pageIndex = Math.max(0, Math.floor(y / (defaultPageHeight + pageGap))); + pageLocalY = y - pageIndex * (defaultPageHeight + pageGap); + } const headerRegion = this.#headerRegions.get(pageIndex); if (headerRegion && this.#pointInRegion(headerRegion, x, pageLocalY)) { @@ -648,6 +680,17 @@ export class HeaderFooterSessionManager { return; } + // Clean up previous session if switching between pages while in editing mode + if (this.#session.mode !== 'body') { + if (this.#activeEditor) { + this.#activeEditor.setEditable(false); + this.#activeEditor.setOptions({ documentMode: 'viewing' }); + } + this.#overlayManager.hideEditingOverlay(); + this.#activeEditor = null; + this.#session = { mode: 'body' }; + } + const descriptor = this.#resolveDescriptorForRegion(region); if (!descriptor) { console.warn('[HeaderFooterSessionManager] No descriptor found for region:', region); @@ -713,15 +756,15 @@ export class HeaderFooterSessionManager { return; } - const layout = this.#headerLayoutResults?.[0]?.layout; + const bodyPageCount = this.#deps?.getBodyPageCount() ?? 1; let editor; try { editor = await this.#headerFooterManager.ensureEditor(descriptor, { editorHost, availableWidth: region.width, availableHeight: region.height, - currentPageNumber: region.pageNumber, - totalPageCount: layout?.pages?.length ?? 1, + currentPageNumber: parseInt(region.displayPageNumber ?? '', 10) || region.pageNumber, + totalPageCount: bodyPageCount, }); } catch (editorError) { console.error('[HeaderFooterSessionManager] Error creating editor:', editorError); @@ -795,7 +838,7 @@ export class HeaderFooterSessionManager { headerId: descriptor.id, sectionType: descriptor.variant ?? region.sectionType ?? null, pageIndex: region.pageIndex, - pageNumber: region.pageNumber, + pageNumber: parseInt(region.displayPageNumber ?? '', 10) || region.pageNumber, }; this.clearHover(); @@ -1360,9 +1403,14 @@ export class HeaderFooterSessionManager { return null; } - // PRIORITY 1: Try per-rId layout - if (sectionRId && layoutsByRId.has(sectionRId)) { - const rIdLayout = layoutsByRId.get(sectionRId); + // PRIORITY 1: Try per-rId layout (composite key first for per-section margins, then plain rId) + const compositeKey = sectionRId ? `${sectionRId}::s${sectionIndex}` : undefined; + const rIdLayoutKey = + (compositeKey && layoutsByRId.has(compositeKey) && compositeKey) || + (sectionRId && layoutsByRId.has(sectionRId) && sectionRId) || + undefined; + if (rIdLayoutKey) { + const rIdLayout = layoutsByRId.get(rIdLayoutKey); if (!rIdLayout) { console.warn( `[HeaderFooterSessionManager] Inconsistent state: layoutsByRId.has('${sectionRId}') returned true but get() returned undefined`, @@ -1377,6 +1425,10 @@ export class HeaderFooterSessionManager { kind === 'footer' ? this.#stripFootnoteReserveFromBottomMargin(margins, page ?? null) : margins; const box = this.#computeDecorationBox(kind, decorationMargins, pageHeight); + // When a table grid width exceeds the section content width, the layout + // was computed at the wider effectiveWidth. Use it for the container (SD-1837). + const effectiveWidth = rIdLayout.effectiveWidth ?? box.width; + const rawLayoutHeight = rIdLayout.layout.height ?? 0; const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0); @@ -1390,12 +1442,12 @@ export class HeaderFooterSessionManager { contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight, offset: metrics.offset, marginLeft: box.x, - contentWidth: box.width, + contentWidth: effectiveWidth, headerId: sectionRId, sectionType: headerFooterType, minY: layoutMinY, - box: { x: box.x, y: metrics.offset, width: box.width, height: metrics.containerHeight }, - hitRegion: { x: box.x, y: metrics.offset, width: box.width, height: metrics.containerHeight }, + box: { x: box.x, y: metrics.offset, width: effectiveWidth, height: metrics.containerHeight }, + hitRegion: { x: box.x, y: metrics.offset, width: effectiveWidth, height: metrics.containerHeight }, }; } } diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index 5115496704..0f1ad95034 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -122,9 +122,17 @@ export type EditorInputCallbacks = { /** Emit event */ emit?: (event: string, payload: unknown) => void; /** Normalize client point to layout coordinates */ - normalizeClientPoint?: (clientX: number, clientY: number) => { x: number; y: number } | null; + normalizeClientPoint?: ( + clientX: number, + clientY: number, + ) => { x: number; y: number; pageIndex?: number; pageLocalY?: number } | null; /** Hit test header/footer region */ - hitTestHeaderFooterRegion?: (x: number, y: number) => HeaderFooterRegion | null; + hitTestHeaderFooterRegion?: ( + x: number, + y: number, + pageIndex?: number, + pageLocalY?: number, + ) => HeaderFooterRegion | null; /** Exit header/footer mode */ exitHeaderFooterMode?: () => void; /** Activate header/footer region */ @@ -843,12 +851,21 @@ export class EditorInputManager { // Check header/footer session state const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; if (sessionMode !== 'body') { - if (this.#handleClickInHeaderFooterMode(event, x, y)) return; + if (this.#handleClickInHeaderFooterMode(event, x, y, normalizedPoint.pageIndex, normalizedPoint.pageLocalY)) + return; } // Check for header/footer region hit - const headerFooterRegion = this.#callbacks.hitTestHeaderFooterRegion?.(x, y); - if (headerFooterRegion) return; // Will be handled by double-click + const headerFooterRegion = this.#callbacks.hitTestHeaderFooterRegion?.( + x, + y, + normalizedPoint.pageIndex, + normalizedPoint.pageLocalY, + ); + if (headerFooterRegion) { + event.preventDefault(); // Prevent native selection before double-click handles it + return; // Will be handled by double-click + } // Get hit position const viewportHost = this.#deps.getViewportHost(); @@ -1113,16 +1130,15 @@ export class EditorInputManager { const layoutState = this.#deps.getLayoutState(); if (!layoutState.layout) return; - const viewportHost = this.#deps.getViewportHost(); - const visibleHost = this.#deps.getVisibleHost(); - const zoom = this.#deps.getZoom(); - const rect = viewportHost.getBoundingClientRect(); - const scrollLeft = visibleHost.scrollLeft ?? 0; - const scrollTop = visibleHost.scrollTop ?? 0; - const x = (event.clientX - rect.left + scrollLeft) / zoom; - const y = (event.clientY - rect.top + scrollTop) / zoom; - - const region = this.#callbacks.hitTestHeaderFooterRegion?.(x, y); + const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY); + if (!normalized) return; + + const region = this.#callbacks.hitTestHeaderFooterRegion?.( + normalized.x, + normalized.y, + normalized.pageIndex, + normalized.pageLocalY, + ); if (region) { event.preventDefault(); event.stopPropagation(); @@ -1280,7 +1296,13 @@ export class EditorInputManager { this.#focusEditorAtFirstPosition(); } - #handleClickInHeaderFooterMode(event: PointerEvent, x: number, y: number): boolean { + #handleClickInHeaderFooterMode( + event: PointerEvent, + x: number, + y: number, + pageIndex?: number, + pageLocalY?: number, + ): boolean { const session = this.#deps?.getHeaderFooterSession(); const activeEditorHost = session?.overlayManager?.getActiveEditorHost?.(); const clickedInsideEditorHost = @@ -1290,13 +1312,16 @@ export class EditorInputManager { return true; // Let editor handle it } - const headerFooterRegion = this.#callbacks.hitTestHeaderFooterRegion?.(x, y); + const headerFooterRegion = this.#callbacks.hitTestHeaderFooterRegion?.(x, y, pageIndex, pageLocalY); if (!headerFooterRegion) { this.#callbacks.exitHeaderFooterMode?.(); return false; // Continue to body click handling } - return true; // In header/footer region + // Click is in a H/F region on a different page — don't consume the event. + // Let it fall through to the existing footer region check in #handlePointerDown + // which properly calls event.preventDefault() before the dblclick handler activates it. + return false; } #handleInlineImageClick( @@ -1546,7 +1571,7 @@ export class EditorInputManager { } } - #handleHover(normalized: { x: number; y: number }): void { + #handleHover(normalized: { x: number; y: number; pageIndex?: number; pageLocalY?: number }): void { if (!this.#deps) return; const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; @@ -1560,7 +1585,12 @@ export class EditorInputManager { return; } - const region = this.#callbacks.hitTestHeaderFooterRegion?.(normalized.x, normalized.y); + const region = this.#callbacks.hitTestHeaderFooterRegion?.( + normalized.x, + normalized.y, + normalized.pageIndex, + normalized.pageLocalY, + ); if (!region) { this.#callbacks.clearHoverRegion?.(); return; diff --git a/packages/super-editor/src/core/presentation-editor/types.ts b/packages/super-editor/src/core/presentation-editor/types.ts index 3b6d446d8c..4ef220a5d5 100644 --- a/packages/super-editor/src/core/presentation-editor/types.ts +++ b/packages/super-editor/src/core/presentation-editor/types.ts @@ -347,6 +347,8 @@ export type HeaderFooterRegion = { sectionType?: string; pageIndex: number; pageNumber: number; + /** Section-aware display page number (e.g. "7" when physical page is 10 due to section numbering) */ + displayPageNumber?: string; localX: number; localY: number; width: number; diff --git a/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.js index 665d942911..7da2bd5160 100644 --- a/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -63,6 +63,23 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { i++; continue; } + + // For unhandled fldSimple fields (FILENAME, DOCPROPERTY, etc.), + // unwrap the field and emit child content directly. + // The child elements (w:r > w:t) contain the cached display value + // that Word rendered when the document was last saved. + const childElements = node.elements || []; + if (childElements.length > 0) { + for (const child of childElements) { + if (Array.isArray(child.elements)) { + const childResult = preProcessPageFieldsOnly(child.elements, depth + 1); + child.elements = childResult.processedNodes; + } + processedNodes.push(child); + } + i++; + continue; + } } if (fldType === 'begin') { @@ -101,6 +118,17 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { } } + // Handle w:pgNum — legacy OOXML element for current page number. + // Appears as . Treat identically + // to a PAGE field by emitting sd:autoPageNumber. + if (node.name === 'w:r' && node.elements?.some((el) => el.name === 'w:pgNum')) { + const rPr = node.elements.find((el) => el.name === 'w:rPr') || null; + const processedField = preProcessPageInstruction([], '', rPr); + processedNodes.push(...processedField); + i++; + continue; + } + // Not a field or incomplete field - recursively process children and add if (Array.isArray(node.elements)) { const childResult = preProcessPageFieldsOnly(node.elements, depth + 1); diff --git a/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.test.js b/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.test.js index deffd3ddfc..240dbc2453 100644 --- a/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.test.js +++ b/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.test.js @@ -150,9 +150,46 @@ describe('preProcessPageFieldsOnly', () => { const result = preProcessPageFieldsOnly(nodes); - // Should pass through unchanged (processed recursively) + // Unhandled fldSimple should unwrap to its child content (w:r elements) + // so the cached display text is rendered instead of being lost in a passthrough node expect(result.processedNodes).toHaveLength(1); - expect(result.processedNodes[0].name).toBe('w:fldSimple'); + expect(result.processedNodes[0].name).toBe('w:r'); + expect(result.processedNodes[0].elements[0].elements[0].text).toBe('John Doe'); + }); + }); + + describe('legacy w:pgNum element', () => { + it('should convert w:pgNum to sd:autoPageNumber', () => { + const nodes = [ + { + name: 'w:r', + elements: [{ name: 'w:pgNum', type: 'element' }], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); + }); + + it('should preserve rPr from w:pgNum run', () => { + const nodes = [ + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: [{ name: 'w:sz', attributes: { 'w:val': '20' } }] }, + { name: 'w:pgNum', type: 'element' }, + ], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); + expect(result.processedNodes[0].elements).toBeDefined(); + expect(result.processedNodes[0].elements[0].name).toBe('w:rPr'); }); }); diff --git a/packages/super-editor/src/extensions/page-number/page-number.js b/packages/super-editor/src/extensions/page-number/page-number.js index a36cc60389..35c4242efa 100644 --- a/packages/super-editor/src/extensions/page-number/page-number.js +++ b/packages/super-editor/src/extensions/page-number/page-number.js @@ -192,7 +192,7 @@ export const TotalPageCount = Node.create({ const pageNumberType = schema.nodes?.['total-page-number']; if (!pageNumberType) return false; - const currentPages = editor?.options?.parentEditor?.currentTotalPages || 1; + const currentPages = editor?.options?.totalPageCount || editor?.options?.parentEditor?.currentTotalPages || 1; const pageNumberNode = { type: 'total-page-number', content: [{ type: 'text', text: String(currentPages) }], @@ -224,7 +224,7 @@ const getNodeAttributes = (nodeName, editor) => { }; case 'total-page-number': return { - text: editor.options.parentEditor?.currentTotalPages || '1', + text: editor.options.totalPageCount || editor.options.parentEditor?.currentTotalPages || '1', className: 'sd-editor-auto-total-pages', dataId: 'auto-total-pages', ariaLabel: 'Total page count node', @@ -298,6 +298,14 @@ export class AutoPageNumberNodeView { const currentType = this.node?.type?.name; if (!incomingType || incomingType !== currentType) return false; this.node = node; + + // Refresh displayed text when editor options change (e.g. currentPageNumber) + const attrs = getNodeAttributes(this.node.type.name, this.editor); + const newText = String(attrs.text); + if (this.dom.textContent !== newText) { + this.dom.textContent = newText; + } + return true; } } diff --git a/packages/super-editor/src/extensions/page-number/page-number.test.js b/packages/super-editor/src/extensions/page-number/page-number.test.js index 407246fcdc..2e8ac82426 100644 --- a/packages/super-editor/src/extensions/page-number/page-number.test.js +++ b/packages/super-editor/src/extensions/page-number/page-number.test.js @@ -46,7 +46,7 @@ describe('PageNumber commands', () => { nodes: { 'total-page-number': {} }, nodeFromJSON: vi.fn().mockImplementation((json) => json), }; - const editor = { options: { isHeaderOrFooter: true, parentEditor: { currentTotalPages: 7 } } }; + const editor = { options: { isHeaderOrFooter: true, totalPageCount: 7, parentEditor: { currentTotalPages: 7 } } }; const dispatch = vi.fn(); const result = commands.addTotalPageCount()({ @@ -184,7 +184,7 @@ describe('AutoPageNumberNodeView', () => { const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) }; const state = { doc, tr }; const editor = { - options: { parentEditor: { currentTotalPages: 12 } }, + options: { totalPageCount: 12, parentEditor: { currentTotalPages: 12 } }, state, view: { state, dispatch: vi.fn() }, }; diff --git a/packages/super-editor/src/extensions/pagination/pagination-helpers.js b/packages/super-editor/src/extensions/pagination/pagination-helpers.js index fe0518b156..1016adce41 100644 --- a/packages/super-editor/src/extensions/pagination/pagination-helpers.js +++ b/packages/super-editor/src/extensions/pagination/pagination-helpers.js @@ -245,6 +245,11 @@ export const createHeaderFooterEditor = ({ pm.style.outline = 'none'; pm.style.border = 'none'; + // CSS class scopes header/footer-specific table rules (prosemirror.css). + // Using a class instead of inline styles because TableView.updateTable() + // does `table.style.cssText = …` which wipes all inline styles on updates. + pm.classList.add('sd-header-footer'); + pm.setAttribute('role', 'textbox'); pm.setAttribute('aria-multiline', true); pm.setAttribute('aria-label', `${type} content area. Double click to start typing.`);