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.`);