Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 33 additions & 15 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ type OptionalBlockMeasurePair = {

type PageDecorationPayload = {
fragments: Fragment[];
/** Resolved items aligned 1:1 with `fragments`. Same length, same order.
* Absent when provider has no resolved data (painter falls back to blockLookup). */
items?: ResolvedPaintItem[];
height: number;
/** Optional measured content height to aid bottom alignment in footers. */
contentHeight?: number;
Expand Down Expand Up @@ -2334,16 +2337,13 @@ export class DomPainter {
* Used to determine special Y positioning for page-relative anchored media
* in header/footer decoration sections.
*/
private isPageRelativeAnchoredFragment(fragment: Fragment): boolean {
private isPageRelativeAnchoredFragment(fragment: Fragment, resolvedItem?: ResolvedPaintItem): boolean {
if (fragment.kind !== 'image' && fragment.kind !== 'drawing') {
return false;
}
const lookup = this.blockLookup.get(fragment.blockId);
if (!lookup) {
return false;
}
const block = lookup.block;
if (block.kind !== 'image' && block.kind !== 'drawing') {
const resolvedBlock = resolvedItem && 'block' in resolvedItem ? resolvedItem.block : undefined;
const block = resolvedBlock ?? this.blockLookup.get(fragment.blockId)?.block;
if (!block || (block.kind !== 'image' && block.kind !== 'drawing')) {
return false;
}
return block.anchor?.vRelativeFrom === 'page';
Expand Down Expand Up @@ -2463,9 +2463,10 @@ export class DomPainter {
const contentHeight =
typeof data.contentHeight === 'number'
? data.contentHeight
: data.fragments.reduce((max, f) => {
: data.fragments.reduce((max, f, fi) => {
const resolvedItem = data.items?.[fi];
const fragHeight =
'height' in f && typeof f.height === 'number' ? f.height : this.estimateFragmentHeight(f);
'height' in f && typeof f.height === 'number' ? f.height : this.estimateFragmentHeight(f, resolvedItem);
return Math.max(max, f.y + Math.max(0, fragHeight));
}, 0);
// Offset to push content to bottom of container
Expand All @@ -2482,7 +2483,7 @@ export class DomPainter {
};

// Compute between-border flags for header/footer paragraph fragments
const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, this.blockLookup);
const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, this.blockLookup, data.items);

// Separate behindDoc fragments from normal fragments.
// Prefer explicit fragment.behindDoc when present. Keep zIndex===0 as a
Expand Down Expand Up @@ -2517,8 +2518,15 @@ export class DomPainter {
// By inserting at the beginning and using z-index: 0, they render below body content
// which also has z-index values but comes later in DOM order.
behindDocFragments.forEach(({ fragment, originalIndex }) => {
const fragEl = this.renderFragment(fragment, context, undefined, betweenBorderFlags.get(originalIndex));
const isPageRelative = this.isPageRelativeAnchoredFragment(fragment);
const resolvedItem = data.items?.[originalIndex];
const fragEl = this.renderFragment(
fragment,
context,
undefined,
betweenBorderFlags.get(originalIndex),
resolvedItem,
);
const isPageRelative = this.isPageRelativeAnchoredFragment(fragment, resolvedItem);

let pageY: number;
if (isPageRelative && kind === 'footer') {
Expand All @@ -2541,8 +2549,15 @@ export class DomPainter {

// Render normal fragments in the header/footer container
normalFragments.forEach(({ fragment, originalIndex }) => {
const fragEl = this.renderFragment(fragment, context, undefined, betweenBorderFlags.get(originalIndex));
const isPageRelative = this.isPageRelativeAnchoredFragment(fragment);
const resolvedItem = data.items?.[originalIndex];
const fragEl = this.renderFragment(
fragment,
context,
undefined,
betweenBorderFlags.get(originalIndex),
resolvedItem,
);
const isPageRelative = this.isPageRelativeAnchoredFragment(fragment, resolvedItem);

if (isPageRelative && kind === 'footer') {
// Footer page-relative: fragment.y is normalized to band-local coords
Expand Down Expand Up @@ -6670,7 +6685,10 @@ export class DomPainter {
* @param fragment - The fragment to estimate height for
* @returns Estimated height in pixels, or 0 if height cannot be determined
*/
private estimateFragmentHeight(fragment: Fragment): number {
private estimateFragmentHeight(fragment: Fragment, resolvedItem?: ResolvedPaintItem): number {
if (resolvedItem && 'height' in resolvedItem && typeof resolvedItem.height === 'number') {
return resolvedItem.height;
}
const lookup = this.blockLookup.get(fragment.blockId);
const measure = lookup?.measure;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,17 @@
* @module presentation-editor/header-footer/HeaderFooterSessionManager
*/

import type { Layout, FlowBlock, Measure, Page, SectionMetadata, Fragment } from '@superdoc/contracts';
import type {
Layout,
FlowBlock,
Measure,
Page,
SectionMetadata,
Fragment,
ResolvedHeaderFooterLayout,
} from '@superdoc/contracts';
import type { PageDecorationProvider } from '@superdoc/painter-dom';
import { resolveHeaderFooterLayout } from '@superdoc/layout-resolved';

import type { Editor } from '../../Editor.js';
import type {
Expand Down Expand Up @@ -176,6 +185,19 @@ export type SessionManagerCallbacks = {
}) => void;
};

// =============================================================================
// Helpers
// =============================================================================

/**
* Resolve a `HeaderFooterLayoutResult` into a `ResolvedHeaderFooterLayout`.
* Paired with the originals so the decoration provider can deliver aligned
* `items` alongside `fragments`.
*/
function resolveResult(result: HeaderFooterLayoutResult): ResolvedHeaderFooterLayout {
return resolveHeaderFooterLayout(result.layout, result.blocks, result.measures);
}

// =============================================================================
// HeaderFooterSessionManager
// =============================================================================
Expand Down Expand Up @@ -203,6 +225,12 @@ export class HeaderFooterSessionManager {
#headerLayoutsByRId: Map<string, HeaderFooterLayoutResult> = new Map();
#footerLayoutsByRId: Map<string, HeaderFooterLayoutResult> = new Map();

// Resolved layouts (aligned 1:1 with the results above)
#resolvedHeaderLayouts: ResolvedHeaderFooterLayout[] | null = null;
#resolvedFooterLayouts: ResolvedHeaderFooterLayout[] | null = null;
#resolvedHeaderByRId: Map<string, ResolvedHeaderFooterLayout> = new Map();
#resolvedFooterByRId: Map<string, ResolvedHeaderFooterLayout> = new Map();

// Decoration providers
#headerDecorationProvider: PageDecorationProvider | undefined;
#footerDecorationProvider: PageDecorationProvider | undefined;
Expand Down Expand Up @@ -331,6 +359,7 @@ export class HeaderFooterSessionManager {
/** Set header layout results */
set headerLayoutResults(results: HeaderFooterLayoutResult[] | null) {
this.#headerLayoutResults = results;
this.#resolvedHeaderLayouts = results ? results.map(resolveResult) : null;
}

/** Footer layout results */
Expand All @@ -341,6 +370,7 @@ export class HeaderFooterSessionManager {
/** Set footer layout results */
set footerLayoutResults(results: HeaderFooterLayoutResult[] | null) {
this.#footerLayoutResults = results;
this.#resolvedFooterLayouts = results ? results.map(resolveResult) : null;
}

/** Header layouts by rId */
Expand Down Expand Up @@ -431,6 +461,8 @@ export class HeaderFooterSessionManager {
): void {
this.#headerLayoutResults = headerResults;
this.#footerLayoutResults = footerResults;
this.#resolvedHeaderLayouts = headerResults ? headerResults.map(resolveResult) : null;
this.#resolvedFooterLayouts = footerResults ? footerResults.map(resolveResult) : null;
}

/**
Expand Down Expand Up @@ -1274,10 +1306,20 @@ export class HeaderFooterSessionManager {
layout: Layout,
sectionMetadata: SectionMetadata[],
): Promise<void> {
return await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, {
await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, {
headerLayoutsByRId: this.#headerLayoutsByRId,
footerLayoutsByRId: this.#footerLayoutsByRId,
});

// Rebuild resolved maps aligned 1:1 with the raw rId maps.
this.#resolvedHeaderByRId.clear();
for (const [key, result] of this.#headerLayoutsByRId) {
this.#resolvedHeaderByRId.set(key, resolveResult(result));
}
this.#resolvedFooterByRId.clear();
for (const [key, result] of this.#footerLayoutsByRId) {
this.#resolvedFooterByRId.set(key, resolveResult(result));
}
}

#computeMetrics(
Expand Down Expand Up @@ -1578,6 +1620,8 @@ export class HeaderFooterSessionManager {
createDecorationProvider(kind: 'header' | 'footer', layout: Layout): PageDecorationProvider | undefined {
const results = kind === 'header' ? this.#headerLayoutResults : this.#footerLayoutResults;
const layoutsByRId = kind === 'header' ? this.#headerLayoutsByRId : this.#footerLayoutsByRId;
const resolvedResults = kind === 'header' ? this.#resolvedHeaderLayouts : this.#resolvedFooterLayouts;
const resolvedByRId = kind === 'header' ? this.#resolvedHeaderByRId : this.#resolvedFooterByRId;

if ((!results || results.length === 0) && (!layoutsByRId || layoutsByRId.size === 0)) {
return undefined;
Expand Down Expand Up @@ -1652,6 +1696,15 @@ export class HeaderFooterSessionManager {
const slotPage = this.#findPageForNumber(rIdLayout.layout.pages, pageNumber);
if (slotPage) {
const fragments = slotPage.fragments ?? [];
const resolvedLayout = resolvedByRId.get(rIdLayoutKey);
const resolvedSlotPage = resolvedLayout?.pages.find((p) => p.number === slotPage.number);
const resolvedItems = resolvedSlotPage?.items;
if (resolvedItems && resolvedItems.length !== fragments.length) {
console.warn(
`[HeaderFooterSessionManager] Resolved items length (${resolvedItems.length}) does not match fragments length (${fragments.length}) for rId '${rIdLayoutKey}' page ${pageNumber}. Dropping items.`,
);
}
const alignedItems = resolvedItems && resolvedItems.length === fragments.length ? resolvedItems : undefined;
const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? layoutOptions.pageSize?.h ?? defaultPageSize.h;
const margins = pageMargins ?? layout.pages[0]?.margins ?? layoutOptions.margins ?? defaultMargins;
const decorationMargins =
Expand All @@ -1671,6 +1724,7 @@ export class HeaderFooterSessionManager {

return {
fragments: normalizedFragments,
items: alignedItems,
Comment on lines 1724 to +1727
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Normalize resolved item coordinates with shifted fragments

When layoutMinY < 0, this branch shifts fragment coordinates (y: f.y - layoutMinY) but returns items unchanged. After this refactor, renderer.renderFragment prefers resolvedItem.y (via applyResolvedFragmentFrame), so non-anchored header/footer fragments are positioned using stale pre-normalization Y values while the rest of the decoration math uses normalized fragment coordinates. In documents where header/footer content extends above y=0, this causes visible vertical misplacement/clipping; the same mismatch is also present in the variant fallback branch.

Useful? React with 👍 / 👎.

height: metrics.containerHeight,
contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight,
offset: metrics.offset,
Expand All @@ -1691,7 +1745,8 @@ export class HeaderFooterSessionManager {
return null;
}

const variant = results.find((entry) => entry.type === headerFooterType);
const variantIndex = results.findIndex((entry) => entry.type === headerFooterType);
const variant = variantIndex >= 0 ? results[variantIndex] : undefined;
if (!variant || !variant.layout?.pages?.length) {
return null;
}
Expand All @@ -1702,6 +1757,17 @@ export class HeaderFooterSessionManager {
}
const fragments = slotPage.fragments ?? [];

const resolvedVariant = resolvedResults?.[variantIndex];
const resolvedVariantPage = resolvedVariant?.pages.find((p) => p.number === slotPage.number);
const resolvedVariantItems = resolvedVariantPage?.items;
if (resolvedVariantItems && resolvedVariantItems.length !== fragments.length) {
console.warn(
`[HeaderFooterSessionManager] Resolved items length (${resolvedVariantItems.length}) does not match fragments length (${fragments.length}) for variant '${headerFooterType}' page ${pageNumber}. Dropping items.`,
);
}
const alignedVariantItems =
resolvedVariantItems && resolvedVariantItems.length === fragments.length ? resolvedVariantItems : undefined;

const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? layoutOptions.pageSize?.h ?? defaultPageSize.h;
const margins = pageMargins ?? layout.pages[0]?.margins ?? layoutOptions.margins ?? defaultMargins;
const decorationMargins =
Expand All @@ -1718,6 +1784,7 @@ export class HeaderFooterSessionManager {

return {
fragments: normalizedFragments,
items: alignedVariantItems,
height: metrics.containerHeight,
contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight,
offset: metrics.offset,
Expand Down Expand Up @@ -1798,6 +1865,10 @@ export class HeaderFooterSessionManager {
this.#footerLayoutResults = null;
this.#headerLayoutsByRId.clear();
this.#footerLayoutsByRId.clear();
this.#resolvedHeaderLayouts = null;
this.#resolvedFooterLayouts = null;
this.#resolvedHeaderByRId.clear();
this.#resolvedFooterByRId.clear();

// Clear decoration providers
this.#headerDecorationProvider = undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ vi.mock('../../header-footer/HeaderFooterRegistryInit.js', () => ({
}));

import type { Editor } from '../../Editor.js';
import type { FlowBlock, HeaderFooterLayout, Layout, Measure, ParaFragment } from '@superdoc/contracts';
import type { HeaderFooterLayoutResult } from '@superdoc/layout-bridge';
import {
HeaderFooterSessionManager,
type SessionManagerDependencies,
Expand Down Expand Up @@ -261,4 +263,80 @@ describe('HeaderFooterSessionManager', () => {

expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]);
});

describe('createDecorationProvider — resolved items', () => {
function buildHeaderResult(): HeaderFooterLayoutResult {
const paraFragment: ParaFragment = {
kind: 'para',
blockId: 'p1',
fromLine: 0,
toLine: 1,
x: 72,
y: 10,
width: 468,
};
const layout: HeaderFooterLayout = {
height: 50,
pages: [{ number: 1, fragments: [paraFragment] }],
};
const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
const measures: Measure[] = [
{
kind: 'paragraph',
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }],
totalHeight: 18,
},
];
return { kind: 'header', type: 'default', layout, blocks, measures };
}

it('delivers items aligned 1:1 with fragments when variant layout is used', () => {
const deps: SessionManagerDependencies = {
getLayoutOptions: vi.fn(() => ({})),
getPageElement: vi.fn(() => null),
scrollPageIntoView: vi.fn(),
waitForPageMount: vi.fn(async () => true),
convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })),
isViewLocked: vi.fn(() => false),
getBodyPageHeight: vi.fn(() => 800),
notifyInputBridgeTargetChanged: vi.fn(),
scheduleRerender: vi.fn(),
setPendingDocChange: vi.fn(),
getBodyPageCount: vi.fn(() => 1),
};

manager = new HeaderFooterSessionManager({
painterHost,
visibleHost,
selectionOverlay,
editor: createMainEditorStub(),
defaultPageSize: { w: 612, h: 792 },
defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
});
manager.setDependencies(deps);
manager.headerFooterIdentifier = {
headerIds: { default: 'rId-header-default', first: null, even: null, odd: null },
footerIds: { default: null, first: null, even: null, odd: null },
titlePg: false,
alternateHeaders: false,
};
manager.setLayoutResults([buildHeaderResult()], null);

const layout: Layout = {
version: 1,
flowMode: 'paginated',
pageGap: 0,
pageSize: { w: 612, h: 792 },
pages: [{ number: 1, margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 } } as never],
} as unknown as Layout;
const provider = manager.createDecorationProvider('header', layout);
expect(provider).toBeDefined();
const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0]);
expect(payload).not.toBeNull();
expect(payload!.fragments).toHaveLength(1);
expect(payload!.items).toBeDefined();
expect(payload!.items!.length).toBe(payload!.fragments.length);
expect(payload!.items![0]!.blockId).toBe('p1');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ vi.mock('y-prosemirror', () => ({

vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({

vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({

vi.mock('@superdoc/layout-resolved', () => ({
resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })),
resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })),
}));

describe('PresentationEditor - Draggable Annotation Focus Suppression (SD-1179)', () => {
Expand Down
Loading
Loading