Skip to content

fix(layout): per-section footer constraints for multi-section docs (SD-1837)#2022

Open
tupizz wants to merge 2 commits intomainfrom
tadeu/sd-1837-footer-per-section-margins
Open

fix(layout): per-section footer constraints for multi-section docs (SD-1837)#2022
tupizz wants to merge 2 commits intomainfrom
tadeu/sd-1837-footer-per-section-margins

Conversation

@tupizz
Copy link
Contributor

@tupizz tupizz commented Feb 13, 2026

Demo on the bugs and proposed solution

CleanShot.2026-02-13.at.16.18.54.mp4

Summary

Fix per-section footer constraints for multi-section documents and fix footer editing interaction bugs.

Per-section footer layout (SD-1837)

  • Footer layout constraints now use per-section page margins instead of global defaults, so sections with different bottom margins render footers at the correct position
  • fldSimple fields (e.g., PAGE, NUMPAGES) in footers now render correctly during layout

Footer editing: hover tooltip on all pages

  • Bug: "Double click to edit footer" tooltip only appeared on page 1
  • Root cause: normalizeClientPoint did not detect which page the pointer was over, so hitTestRegion always derived page index 0 from the Y coordinate
  • Fix: Detect the page element via elementsFromPoint in normalizeClientPoint and pass pageIndex + pageLocalY (computed from the page element's bounding rect) to hitTestRegion

Footer editing: double-click selecting body text

  • Bug: Double-clicking a footer sometimes selected text from the document body instead of entering footer edit mode
  • Root cause: pointerdown was not calling event.preventDefault() when the click landed on a footer region, allowing the browser to perform native text selection
  • Fix: Call event.preventDefault() in #handlePointerDown when a header/footer region is detected

Footer editing: clicking page N redirecting to page 1

  • Bug: Clicking on any page would redirect/scroll to page 1
  • Root cause: normalizeClientPoint was subtracting getPageOffsetY from the Y coordinate, making it page-local. But all downstream consumers (clickToPosition, selection handling) expect global layout Y
  • Fix: Keep y as global layout coordinates in normalizeClientPoint. Compute pageLocalY separately from the page element's getBoundingClientRect and pass it only to the header/footer hit testing path

Footer editing: switching pages scrolls to wrong page

  • Bug: When already editing a footer on page 13 and double-clicking page 16's footer, the view scrolled back to page 13
  • Root cause: #handleClickInHeaderFooterMode consumed the pointerdown event without preventDefault, allowing native browser scroll. Additionally, #enterMode did not clean up the previous editing session before creating a new one
  • Fix: Let clicks on H/F regions fall through to the existing footer region handler (which properly calls preventDefault). Clean up the previous session (disable editor, hide overlay) at the start of #enterMode

Footer editing: table layout collapse

  • Bug: Footer table renders as single line of text instead of preserving column widths when editing
  • Root cause: Global CSS table-layout: auto in prosemirror.css causes browsers to ignore explicit <col> widths from createColGroup()
  • Fix: Set table-layout: fixed on tables in the footer ProseMirror editor element after creation

Footer editing: wrong total page count

  • Bug: Footer shows "Page 1 of 1" instead of the correct total page count
  • Root cause: HeaderFooterSessionManager read this.#headerLayoutResults[0].layout.pages.length (the header/footer's own layout, always 1 page) instead of the body layout. Additionally, page-number.js read a non-existent parentEditor.currentTotalPages property
  • Fix: Add getBodyPageCount dependency to read the actual body page count. Update page-number.js to read editor.options.totalPageCount first (set during editor creation)

Footer editing: fldSimple field preprocessing

  • Bug: fldSimple fields with PAGE or NUMPAGES instructions were not preprocessed for standalone rendering
  • Fix: Add handling for fldSimple elements in preProcessPageFieldsOnly alongside existing fldChar-based field processing

Test plan

  • Upload a multi-section DOCX with different page margins per section
  • Verify footer renders at the correct position for each section
  • Hover over footers on pages 1, 2, and later pages — tooltip should appear on all
  • Double-click footer on any page — should enter editing mode without selecting body text
  • While editing a footer, double-click a footer on a different page — should switch correctly
  • Verify footer table preserves column layout when editing
  • Verify page count shows correct total (e.g., "Page 1 of 61")
  • Run pnpm --filter super-editor test — all 5547 tests pass

…ring (SD-1837)

Footer tables now respect per-section margins in multi-section documents.
Previously, all footers were measured using the first section's content
width, causing table overflow on sections with different margins.

Key changes:
- Add margins/pageSize to SectionMetadata for per-section constraint computation
- Refactor layoutPerRIdHeaderFooters to compute per-section constraints
  using composite keys (rId::sN) for section-specific measurements
- Handle pct-based table widths by pre-expanding constraints
- Add rescaleColumnWidths to all table fragment creation sites (SD-1859)
- Unwrap unhandled w:fldSimple fields (FILENAME, etc.) to render cached text
@linear
Copy link

linear bot commented Feb 13, 2026

@github-actions
Copy link
Contributor

github-actions bot commented Feb 13, 2026

Visual diffs detected

Pixel differences were found in visual tests. This is not blocking — reproduce locally with cd tests/visual && pnpm docs:download && pnpm test to review diffs.

@tupizz tupizz self-assigned this Feb 13, 2026
Fix multiple issues with footer editing in presentation mode:

- Fix hover tooltip only showing on first page by detecting page index
  from DOM via elementsFromPoint in normalizeClientPoint
- Fix double-click on footer selecting body text by properly preventing
  default browser behavior on pointerdown in footer regions
- Fix clicking on page N redirecting to page 1 by keeping Y as global
  layout coordinates in normalizeClientPoint (only X is adjusted for
  page centering) and computing page-local Y separately from the page
  element's bounding rect for header/footer hit testing
- Fix switching between footer editors on different pages by cleaning up
  the previous editing session in enterMode before setting up the new one
- Fix footer table layout collapsing by setting table-layout:fixed on
  tables in the footer ProseMirror editor element
- Fix wrong total page count in footer by reading body layout page count
  via getBodyPageCount dependency instead of header layout
- Fix page-number NodeView reading incorrect totalPageCount property
@tupizz tupizz marked this pull request as ready for review February 13, 2026 19:25
Copilot AI review requested due to automatic review settings February 13, 2026 19:25
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes multi-section header/footer layout and editing issues by making header/footer measurement and hit-testing section-aware, improving pointer normalization across pages, and correcting page-number/token handling in header/footer contexts.

Changes:

  • Add per-section header/footer layout constraints (including handling of table width overflow and section-specific margins/page sizes).
  • Fix header/footer editing interactions by improving pointer normalization (page detection, global vs page-local Y) and event handling to avoid native selection/scroll issues.
  • Improve page-number/field rendering: correct total page count sourcing, refresh NodeView text when editor options change, and expand OOXML field preprocessing (e.g., fldSimple, legacy w:pgNum).

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/super-editor/src/extensions/pagination/pagination-helpers.js Adds a scoped class to header/footer ProseMirror roots to apply header/footer-specific table CSS.
packages/super-editor/src/extensions/page-number/page-number.test.js Updates tests to use editor.options.totalPageCount when present.
packages/super-editor/src/extensions/page-number/page-number.js Prefers totalPageCount option, and refreshes NodeView text in update().
packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.test.js Updates/adds tests for fldSimple unwrapping and legacy w:pgNum conversion.
packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.js Adds fldSimple handling changes and legacy w:pgNumsd:autoPageNumber.
packages/super-editor/src/core/presentation-editor/types.ts Extends HeaderFooterRegion with section-aware displayPageNumber.
packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts Passes pageIndex/pageLocalY through normalization + hit testing; prevents native selection on H/F pointerdown.
packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts Improves hit testing with optional known page index/local Y; fixes editor session switching cleanup; sources total pages from body layout.
packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts Keeps Y in global layout coords; detects page via elementsFromPoint and computes pageLocalY from DOM rect.
packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts Updates expectations for new normalization return shape.
packages/super-editor/src/core/presentation-editor/PresentationEditor.ts Wires new hit-test callback signature and provides getBodyPageCount.
packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts Forces page-number DOM refresh after setOptions() updates in header/footer editors.
packages/super-editor/src/core/header-footer/HeaderFooterPerRidLayout.ts Implements per-section header/footer measurement with composite keys and table-width-aware constraints.
packages/super-editor/src/core/header-footer/EditorOverlayManager.ts Allows overlay overflow for footer table overflow behavior.
packages/super-editor/src/assets/styles/elements/prosemirror.css Adds header/footer-scoped table CSS overrides.
packages/layout-engine/pm-adapter/src/sections/analysis.ts Publishes section margins and pageSize into SectionMetadata.
packages/layout-engine/pm-adapter/src/sections/analysis.test.ts Updates expected SectionMetadata to include nullable margins/pageSize.
packages/layout-engine/layout-engine/src/layout-table.ts Adds proportional rescaling of table column widths when clamped to narrower sections.
packages/layout-engine/layout-engine/src/layout-table.test.ts Adds tests for the new column-width rescaling behavior.
packages/layout-engine/layout-bridge/src/incrementalLayout.ts Extends HeaderFooterLayoutResult to include effectiveWidth.
packages/layout-engine/contracts/src/index.ts Extends contracts for SectionMetadata (margins/pageSize) and TableFragment (columnWidths).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +177 to +200
/**
* 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;
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc says this helper returns “Rescaled column widths if clamping occurred, undefined otherwise,” but the implementation returns the original measureColumnWidths when the table fits. Either update the docstring to reflect the actual behavior (always returns widths when available) or change the implementation to return undefined when no rescaling occurs—whichever the callers expect.

Copilot uses AI. Check for mistakes.
Comment on lines +514 to +515
* When knownPageIndex is provided (from normalizeClientPoint), use it directly
* since y is already page-local. Otherwise derive pageIndex from global y.
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hitTestRegion’s doc comment says “use [knownPageIndex] directly since y is already page-local,” but normalizeClientPoint now keeps y as global layout Y and provides page-local Y separately. This comment is misleading and makes the coordinate conventions harder to reason about—please update it to reflect that knownPageLocalY is page-local while y is global.

Suggested change
* When knownPageIndex is provided (from normalizeClientPoint), use it directly
* since y is already page-local. Otherwise derive pageIndex from global y.
* `y` is a global layout Y coordinate. When `knownPageIndex` and `knownPageLocalY`
* are provided (from normalizeClientPoint), use them directly as the page index
* and page-local Y. Otherwise, derive pageIndex and page-local Y from the global `y`.

Copilot uses AI. Check for mistakes.
availableHeight: region.height,
currentPageNumber: region.pageNumber,
totalPageCount: layout?.pages?.length ?? 1,
currentPageNumber: parseInt(region.displayPageNumber ?? '', 10) || region.pageNumber,
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

displayPageNumber is derived from page.numberText, which can be non-decimal (roman numerals, letters, etc.). Using parseInt(region.displayPageNumber, 10) will produce NaN for those formats and silently fall back to the physical pageNumber, so header/footer editing will show the wrong page number for non-decimal numbering schemes. Consider passing the section-aware numeric page index separately (if you need an integer), or loosening the header/footer editor option type/validation to accept a display string for page-number rendering.

Suggested change
currentPageNumber: parseInt(region.displayPageNumber ?? '', 10) || region.pageNumber,
currentPageNumber: region.pageNumber,

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +82
// 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;
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new behavior unwraps all unhandled w:fldSimple nodes and drops the field wrapper/instruction, which conflicts with the function’s stated goal of preserving non-page fields for round-trip fidelity (and with callers like the DOCX importer that rely on that behavior). If this is only meant for rendering cached display text, consider keeping enough metadata to re-export the field later, or applying this unwrapping only in a layout/rendering-specific path rather than during import conversion.

Suggested change
// 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;
}
// preserve the field node and its instruction for round-trip fidelity,
// but still recursively process child content so that any nested PAGE/NUMPAGES
// fields are handled correctly.
const childElements = node.elements || [];
if (Array.isArray(childElements) && childElements.length > 0) {
const childResult = preProcessPageFieldsOnly(childElements, depth + 1);
node.elements = childResult.processedNodes;
}
processedNodes.push(node);
i++;
continue;

Copilot uses AI. Check for mistakes.
}

#normalizeClientPoint(clientX: number, clientY: number): { x: number; y: number } | null {
#normalizeClientPoint(clientX: number, clientY: number): { x: number; y: number; pageIndex?: number } | null {
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#normalizeClientPoint is typed as returning { x, y, pageIndex? }, but normalizeClientPointFromPointer now also returns pageLocalY. Since downstream code (via EditorInputCallbacks) consumes pageLocalY, it would be clearer/safer to include pageLocalY?: number in this method’s return type to avoid future refactors accidentally dropping it.

Suggested change
#normalizeClientPoint(clientX: number, clientY: number): { x: number; y: number; pageIndex?: number } | null {
#normalizeClientPoint(
clientX: number,
clientY: number,
): { x: number; y: number; pageIndex?: number; pageLocalY?: number } | null {

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +142
/**
* 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<number, string> {
const result = new Map<number, string>();
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);
}
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the per-section layout path, resolveRIdPerSection only considers section.headerRefs/footerRefs.default. That means rIds referenced only by first/even/odd variants may never be laid out (and therefore won’t get composite ${rId}::s${sectionIndex} entries), causing createDecorationProvider to miss PRIORITY 1 for those pages and fall back to the variant-based/global layout. Consider including all variant refs when building the section→rId mapping (or at least ensuring every rId in blocksByRId gets a layout result, even if it’s only the fallback constraint).

Copilot uses AI. Check for mistakes.

const result = normalizeClientPoint(options, 200, 150);
expect(result).toEqual({ x: 105, y: 90 });
expect(result).toEqual({ x: 105, y: 90, pageIndex: undefined });
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeClientPoint now always returns a pageLocalY property (even when undefined). This test’s strict toEqual assertion omits pageLocalY, so it will fail because the actual object includes pageLocalY: undefined. Update the expectation to include pageLocalY: undefined or switch the assertion to toMatchObject/property-level checks.

Suggested change
expect(result).toEqual({ x: 105, y: 90, pageIndex: undefined });
expect(result).toEqual({ x: 105, y: 90, pageIndex: undefined, pageLocalY: undefined });

Copilot uses AI. Check for mistakes.
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bca00577f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

y: state.cursorY,
width,
height,
columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply rescaled table column widths during rendering

This writes resized widths into TableFragment.columnWidths, but the painter still renders table cells from measure.columnWidths (see packages/layout-engine/painters/dom/src/table/renderTableFragment.ts), so the new rescaling path is never consumed. In sections where a table fragment is clamped narrower than its measured grid, cells will still use the oversized columns and overflow the fragment width despite this change.

Useful? React with 👍 / 👎.

Comment on lines +689 to +691
this.#overlayManager.hideEditingOverlay();
this.#activeEditor = null;
this.#session = { mode: 'body' };

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore mode state when header/footer switch setup fails

This eagerly resets the active header/footer session to body mode before descriptor/page-mount/editor setup completes. If any later branch returns early (for example missing descriptor, page mount timeout, or overlay creation failure), the method exits without #emitModeChanged()/overlay restoration, which can leave banner/awareness and selection-overlay state inconsistent with the actual mode after a failed page switch.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant