From 63a1c63516df23b800fc297e88c3a1f0947eff41 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 16 Jan 2026 15:53:03 -0800 Subject: [PATCH 1/3] refactor: move region lookup methods to HeaderFooterSessionManager --- .../presentation-editor/PresentationEditor.ts | 628 +----------------- .../HeaderFooterSessionManager.ts | 234 ++++++- 2 files changed, 260 insertions(+), 602 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index ada7eded7..814f128e5 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -68,11 +68,7 @@ import { clickToPosition, getFragmentAtPosition, extractIdentifierFromConverter, - getHeaderFooterType, - getBucketForPageNumber, - getBucketRepresentative, buildMultiSectionIdentifier, - getHeaderFooterTypeForSection, layoutHeaderFooterWithCache as _layoutHeaderFooterWithCache, PageGeometryHelper, } from '@superdoc/layout-bridge'; @@ -3747,427 +3743,20 @@ export class PresentationEditor extends EventEmitter { } } - #updateDecorationProviders(layout: Layout) { - if (this.#headerFooterSession) { - this.#headerFooterSession.headerDecorationProvider = this.#createDecorationProvider('header', layout); - this.#headerFooterSession.footerDecorationProvider = this.#createDecorationProvider('footer', layout); - this.#headerFooterSession.rebuildRegions(layout); - } - } - - /** - * Computes layout metrics for header/footer decoration rendering. - * - * This helper consolidates the calculation of layout height, container height, and vertical offset - * for header/footer content, ensuring consistent metrics across both per-rId and variant-based layouts. - * - * For headers: - * - layoutHeight: The actual measured height of the header content - * - containerHeight: The larger of the box height (space between headerDistance and topMargin) or layoutHeight - * - offset: Always positioned at headerDistance from page top (Word's model) - * - * For footers: - * - layoutHeight: The actual measured height of the footer content - * - containerHeight: The larger of the box height (space between bottomMargin and footerDistance) or layoutHeight - * - offset: Positioned so the container bottom aligns with footerDistance from page bottom - * When content exceeds the nominal space (box.height), the footer extends upward into the body area, - * matching Word's behavior where overflow pushes body text up rather than clipping. - * - * @param kind - Whether this is a header or footer - * @param layoutHeight - The measured height of the header/footer content layout (may be 0 if layout has no height) - * @param box - The computed decoration box containing nominal position and dimensions - * @param pageHeight - Total page height in points - * @param footerMargin - Footer margin (footerDistance) from page bottom, used only for footer offset calculation - * @returns Object containing layoutHeight (validated as non-negative finite number), - * containerHeight (max of box height and layout height), and offset (vertical position from page top) - */ - #computeHeaderFooterMetrics( - kind: 'header' | 'footer', - layoutHeight: number, - box: { height: number; offset: number }, - pageHeight: number, - footerMargin: number, - ): { layoutHeight: number; containerHeight: number; offset: number } { - // Ensure layoutHeight is a valid finite number, default to 0 if not - const validatedLayoutHeight = Number.isFinite(layoutHeight) && layoutHeight >= 0 ? layoutHeight : 0; - - // Container must accommodate both the nominal box height and the actual content height - const containerHeight = Math.max(box.height, validatedLayoutHeight); - - // Calculate vertical offset based on header/footer type - // Headers: Always start at headerDistance (box.offset) from page top - // Footers: Position so container bottom is at footerDistance from page bottom - // - If content is taller than box.height, this extends the footer upward - // - This matches Word's behavior where overflow grows into body area - const offset = kind === 'header' ? box.offset : Math.max(0, pageHeight - footerMargin - containerHeight); - - return { - layoutHeight: validatedLayoutHeight, - containerHeight, - offset, - }; - } - - #createDecorationProvider(kind: 'header' | 'footer', layout: Layout): PageDecorationProvider | undefined { - const results = - kind === 'header' - ? this.#headerFooterSession?.headerLayoutResults - : this.#headerFooterSession?.footerLayoutResults; - const layoutsByRId = - kind === 'header' ? this.#headerFooterSession?.headerLayoutsByRId : this.#headerFooterSession?.footerLayoutsByRId; - - if ((!results || results.length === 0) && (!layoutsByRId || layoutsByRId.size === 0)) { - return undefined; - } - - const multiSectionId = this.#headerFooterSession?.multiSectionIdentifier; - const legacyIdentifier = - this.#headerFooterSession?.headerFooterIdentifier ?? - extractIdentifierFromConverter((this.#editor as Editor & { converter?: unknown }).converter); - - const sectionFirstPageNumbers = new Map(); - for (const p of layout.pages) { - const idx = p.sectionIndex ?? 0; - if (!sectionFirstPageNumbers.has(idx)) { - sectionFirstPageNumbers.set(idx, p.number); - } - } - - return (pageNumber, pageMargins, page) => { - const sectionIndex = page?.sectionIndex ?? 0; - const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); - const sectionPageNumber = - typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; - const headerFooterType = multiSectionId - ? getHeaderFooterTypeForSection(pageNumber, sectionIndex, multiSectionId, { kind, sectionPageNumber }) - : getHeaderFooterType(pageNumber, legacyIdentifier, { kind }); - - // Resolve the section-specific rId for this header/footer variant. - // Implements Word's OOXML inheritance model: - // 1. Try current section's variant (e.g., 'first' header for first page with titlePg) - // 2. If not found, inherit from previous section's same variant - // 3. Final fallback: use current section's 'default' variant - // This ensures documents with multi-section layouts render correctly when sections - // don't explicitly define all header/footer variants (common in Word documents). - let sectionRId: string | undefined; - if (page?.sectionRefs && kind === 'header') { - sectionRId = page.sectionRefs.headerRefs?.[headerFooterType as keyof typeof page.sectionRefs.headerRefs]; - // Step 2: Inherit from previous section if variant not found - if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { - const prevSectionIds = multiSectionId.sectionHeaderIds.get(sectionIndex - 1); - sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; - } - // Step 3: Fall back to current section's 'default' - if (!sectionRId && headerFooterType !== 'default') { - sectionRId = page.sectionRefs.headerRefs?.default; - } - } else if (page?.sectionRefs && kind === 'footer') { - sectionRId = page.sectionRefs.footerRefs?.[headerFooterType as keyof typeof page.sectionRefs.footerRefs]; - // Step 2: Inherit from previous section if variant not found - if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { - const prevSectionIds = multiSectionId.sectionFooterIds.get(sectionIndex - 1); - sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; - } - // Step 3: Fall back to current section's 'default' - if (!sectionRId && headerFooterType !== 'default') { - sectionRId = page.sectionRefs.footerRefs?.default; - } - } - - if (!headerFooterType) { - return null; - } - - // PRIORITY 1: Try per-rId layout if we have a section-specific rId - if (sectionRId && layoutsByRId.has(sectionRId)) { - const rIdLayout = layoutsByRId.get(sectionRId); - // Defensive null check: layoutsByRId.has() should guarantee the value exists, - // but we verify to prevent runtime errors if the Map state is inconsistent - if (!rIdLayout) { - console.warn( - `[PresentationEditor] Inconsistent state: layoutsByRId.has('${sectionRId}') returned true but get() returned undefined`, - ); - // Fall through to PRIORITY 2 (variant-based layout) - } else { - const slotPage = this.#findHeaderFooterPageForPageNumber(rIdLayout.layout.pages, pageNumber); - if (slotPage) { - const fragments = slotPage.fragments ?? []; - - const pageHeight = - page?.size?.h ?? layout.pageSize?.h ?? this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; - const margins = pageMargins ?? layout.pages[0]?.margins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS; - const decorationMargins = - kind === 'footer' ? this.#stripFootnoteReserveFromBottomMargin(margins, page ?? null) : margins; - const box = this.#computeDecorationBox(kind, decorationMargins, pageHeight); - - // Use helper to compute metrics with type safety and consistent logic - const rawLayoutHeight = rIdLayout.layout.height ?? 0; - const metrics = this.#computeHeaderFooterMetrics( - kind, - rawLayoutHeight, - box, - pageHeight, - margins.footer ?? 0, - ); - - // Normalize fragments to start at y=0 if minY is negative - const layoutMinY = rIdLayout.layout.minY ?? 0; - const normalizedFragments = - layoutMinY < 0 ? fragments.map((f) => ({ ...f, y: f.y - layoutMinY })) : fragments; - - return { - fragments: normalizedFragments, - height: metrics.containerHeight, - contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight, - offset: metrics.offset, - marginLeft: box.x, - contentWidth: box.width, - 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, - }, - }; - } - } - } - - // PRIORITY 2: Fall back to variant-based layout (legacy behavior) - if (!results || results.length === 0) { - return null; - } - - const variant = results.find((entry) => entry.type === headerFooterType); - if (!variant || !variant.layout?.pages?.length) { - return null; - } - - const slotPage = this.#findHeaderFooterPageForPageNumber(variant.layout.pages, pageNumber); - if (!slotPage) { - return null; - } - const fragments = slotPage.fragments ?? []; - - const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; - const margins = pageMargins ?? layout.pages[0]?.margins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS; - const decorationMargins = - kind === 'footer' ? this.#stripFootnoteReserveFromBottomMargin(margins, page ?? null) : margins; - const box = this.#computeDecorationBox(kind, decorationMargins, pageHeight); - - // Use helper to compute metrics with type safety and consistent logic - const rawLayoutHeight = variant.layout.height ?? 0; - const metrics = this.#computeHeaderFooterMetrics(kind, rawLayoutHeight, box, pageHeight, margins.footer ?? 0); - const fallbackId = this.#headerFooterSession?.manager?.getVariantId(kind, headerFooterType); - const finalHeaderId = sectionRId ?? fallbackId ?? undefined; - - // Normalize fragments to start at y=0 if minY is negative - const layoutMinY = variant.layout.minY ?? 0; - const normalizedFragments = layoutMinY < 0 ? fragments.map((f) => ({ ...f, y: f.y - layoutMinY })) : fragments; - - return { - fragments: normalizedFragments, - height: metrics.containerHeight, - contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight, - offset: metrics.offset, - marginLeft: box.x, - contentWidth: box.width, - headerId: finalHeaderId, - 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, - }, - }; - }; - } - /** - * Finds the header/footer page layout for a given page number with bucket fallback. - * - * Lookup strategy: - * 1. Try exact match first (find page with matching number) - * 2. If bucketing is used, fall back to the bucket's representative page - * 3. Finally, fall back to the first available page - * - * Digit buckets (for large documents): - * - d1: pages 1-9 → representative page 5 - * - d2: pages 10-99 → representative page 50 - * - d3: pages 100-999 → representative page 500 - * - d4: pages 1000+ → representative page 5000 - * - * @param pages - Array of header/footer layout pages from the variant - * @param pageNumber - Physical page number to find layout for (1-indexed) - * @returns Header/footer page layout, or undefined if no suitable page found + * Update decoration providers for header/footer. + * Delegates to HeaderFooterSessionManager which handles provider creation. */ - #findHeaderFooterPageForPageNumber( - pages: Array<{ number: number; fragments: Fragment[] }>, - pageNumber: number, - ): { number: number; fragments: Fragment[] } | undefined { - if (!pages || pages.length === 0) { - return undefined; - } - - // 1. Try exact match first - const exactMatch = pages.find((p) => p.number === pageNumber); - if (exactMatch) { - return exactMatch; - } - - // 2. If bucketing is used, find the representative for this page's bucket - const bucket = getBucketForPageNumber(pageNumber); - const representative = getBucketRepresentative(bucket); - const bucketMatch = pages.find((p) => p.number === representative); - if (bucketMatch) { - return bucketMatch; - } - - // 3. Final fallback: return the first available page - return pages[0]; - } - - #computeDecorationBox(kind: 'header' | 'footer', pageMargins?: PageMargins, pageHeight?: number) { - const margins = pageMargins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS; - const pageSize = this.#layoutOptions.pageSize ?? DEFAULT_PAGE_SIZE; - const left = margins.left ?? DEFAULT_MARGINS.left!; - const right = margins.right ?? DEFAULT_MARGINS.right!; - const width = Math.max(pageSize.w - (left + right), 1); - const totalHeight = pageHeight ?? pageSize.h; - - // MS Word positioning: - // - Header: ALWAYS starts at headerMargin (headerDistance) from page top - // - Footer: ends at footerMargin from page bottom, can extend up to bottomMargin - // Word keeps header at headerDistance regardless of topMargin value. - // Even for zero-margin docs, the header content starts at headerDistance from page top. - if (kind === 'header') { - const headerMargin = margins.header ?? 0; - const topMargin = margins.top ?? DEFAULT_MARGINS.top ?? 0; - // Height is the space available for header (between headerMargin and topMargin) - const height = Math.max(topMargin - headerMargin, 1); - // Header always starts at headerDistance from page top, matching Word behavior - const offset = headerMargin; - return { x: left, width, height, offset }; - } else { - const footerMargin = margins.footer ?? 0; - const bottomMargin = margins.bottom ?? DEFAULT_MARGINS.bottom ?? 0; - // Height is the space available for footer (between bottomMargin and footerMargin) - const height = Math.max(bottomMargin - footerMargin, 1); - // Position so container bottom is at footerMargin from page bottom - const offset = Math.max(0, totalHeight - footerMargin - height); - return { x: left, width, height, offset }; - } - } - - #stripFootnoteReserveFromBottomMargin(pageMargins: PageMargins, page?: Page | null): PageMargins { - const reserveRaw = (page as Page | null | undefined)?.footnoteReserved; - const reserve = typeof reserveRaw === 'number' && Number.isFinite(reserveRaw) && reserveRaw > 0 ? reserveRaw : 0; - if (!reserve) return pageMargins; - - const bottomRaw = pageMargins.bottom; - const bottom = typeof bottomRaw === 'number' && Number.isFinite(bottomRaw) ? bottomRaw : 0; - const nextBottom = Math.max(0, bottom - reserve); - if (nextBottom === bottom) return pageMargins; - - return { ...pageMargins, bottom: nextBottom }; + #updateDecorationProviders(layout: Layout) { + this.#headerFooterSession?.updateDecorationProviders(layout); } /** - * Computes the expected header/footer section type for a page based on document configuration. - * - * Unlike getHeaderFooterType/getHeaderFooterTypeForSection, this returns the appropriate - * variant even when no header/footer IDs are configured. This is needed to determine - * what variant to create when the user double-clicks an empty header/footer region. - * - * @param kind - Whether this is for a header or footer - * @param page - The page to compute the section type for - * @param sectionFirstPageNumbers - Map of section index to first page number in that section - * @returns The expected section type ('default', 'first', 'even', or 'odd') + * Hit test for header/footer regions at a given point. + * Delegates to HeaderFooterSessionManager which manages region tracking. */ - #computeExpectedSectionType( - kind: 'header' | 'footer', - page: Page, - sectionFirstPageNumbers: Map, - ): HeaderFooterType { - const sectionIndex = page.sectionIndex ?? 0; - const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); - const sectionPageNumber = - typeof firstPageInSection === 'number' ? page.number - firstPageInSection + 1 : page.number; - - // Get titlePg and alternateHeaders settings from identifiers - const multiSectionId = this.#headerFooterSession?.multiSectionIdentifier; - const legacyIdentifier = this.#headerFooterSession?.headerFooterIdentifier; - - let titlePgEnabled = false; - let alternateHeaders = false; - - if (multiSectionId) { - titlePgEnabled = multiSectionId.sectionTitlePg?.get(sectionIndex) ?? multiSectionId.titlePg; - alternateHeaders = multiSectionId.alternateHeaders; - } else if (legacyIdentifier) { - titlePgEnabled = legacyIdentifier.titlePg; - alternateHeaders = legacyIdentifier.alternateHeaders; - } - - // First page of section with titlePg enabled - if (sectionPageNumber === 1 && titlePgEnabled) { - return 'first'; - } - - // Alternate headers (even/odd) - if (alternateHeaders) { - return page.number % 2 === 0 ? 'even' : 'odd'; - } - - return 'default'; - } - - #rebuildHeaderFooterRegions(layout: Layout) { - // Delegate to session manager which handles region building - this.#headerFooterSession?.rebuildRegions(layout); - } - #hitTestHeaderFooterRegion(x: number, y: number): HeaderFooterRegion | null { - const layout = this.#layoutState.layout; - if (!layout) return null; - const pageHeight = layout.pageSize?.h ?? this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.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); - - const headerRegion = this.#headerFooterSession?.headerRegions?.get(pageIndex); - if (headerRegion && this.#pointInRegion(headerRegion, x, pageLocalY)) { - return headerRegion; - } - const footerRegion = this.#headerFooterSession?.footerRegions?.get(pageIndex); - if (footerRegion && this.#pointInRegion(footerRegion, x, pageLocalY)) { - return footerRegion; - } - return null; - } - - #pointInRegion(region: HeaderFooterRegion, x: number, localY: number) { - const withinX = x >= region.localX && x <= region.localX + region.width; - const withinY = localY >= region.localY && localY <= region.localY + region.height; - return withinX && withinY; + return this.#headerFooterSession?.hitTestRegion(x, y, this.#layoutState.layout) ?? null; } #activateHeaderFooterRegion(region: HeaderFooterRegion) { @@ -4193,35 +3782,6 @@ export class PresentationEditor extends EventEmitter { return this.#editor.view?.dom ?? null; } - #emitHeaderFooterModeChanged() { - const session = this.#headerFooterSession?.session ?? { mode: 'body' as const }; - this.emit('headerFooterModeChanged', { - mode: session.mode, - kind: session.kind, - headerId: session.headerId, - sectionType: session.sectionType, - pageIndex: session.pageIndex, - pageNumber: session.pageNumber, - }); - this.#updateAwarenessSession(); - this.#updateModeBanner(); - } - - #emitHeaderFooterEditingContext(editor: Editor) { - const session = this.#headerFooterSession?.session ?? { mode: 'body' as const }; - this.emit('headerFooterEditingContext', { - kind: session.mode, - editor, - headerId: session.headerId, - sectionType: session.sectionType, - }); - this.#announce( - session.mode === 'body' - ? 'Exited header/footer edit mode.' - : `Editing ${session.kind === 'header' ? 'Header' : 'Footer'} (${session.sectionType ?? 'default'})`, - ); - } - #updateAwarenessSession() { const provider = this.#options.collaborationProvider; const awareness = provider?.awareness; @@ -4243,21 +3803,6 @@ export class PresentationEditor extends EventEmitter { }); } - #updateModeBanner() { - if (!this.#modeBanner) return; - const session = this.#headerFooterSession?.session; - if (!session || session.mode === 'body') { - this.#modeBanner.style.display = 'none'; - this.#modeBanner.textContent = ''; - return; - } - const title = session.kind === 'header' ? 'Header' : 'Footer'; - const variant = session.sectionType ?? 'default'; - const page = session.pageNumber != null ? `Page ${session.pageNumber}` : ''; - this.#modeBanner.textContent = `Editing ${title} (${variant}) ${page} – Press Esc to return`; - this.#modeBanner.style.display = 'block'; - } - #announce(message: string) { if (!this.#ariaLiveRegion) return; this.#ariaLiveRegion.textContent = message; @@ -4300,79 +3845,20 @@ export class PresentationEditor extends EventEmitter { this.#announce(announcement.message); } - #validateHeaderFooterEditPermission(): { allowed: boolean; reason?: string } { - if (this.#isViewLocked()) { - return { allowed: false, reason: 'documentMode' }; - } - if (!this.#editor.isEditable) { - return { allowed: false, reason: 'readOnly' }; - } - return { allowed: true }; - } - #emitHeaderFooterEditBlocked(reason: string) { this.emit('headerFooterEditBlocked', { reason }); } #resolveDescriptorForRegion(region: HeaderFooterRegion): HeaderFooterDescriptor | null { - const manager = this.#headerFooterSession?.manager; - if (!manager) return null; - if (region.headerId) { - const descriptor = manager.getDescriptorById(region.headerId); - if (descriptor) return descriptor; - } - if (region.sectionType) { - const descriptors = manager.getDescriptors(region.kind); - const match = descriptors.find((entry) => entry.variant === region.sectionType); - if (match) return match; - } - const descriptors = manager.getDescriptors(region.kind); - if (!descriptors.length) { - console.warn('[PresentationEditor] No descriptor found for region:', region); - return null; - } - return descriptors[0]; + return this.#headerFooterSession?.resolveDescriptorForRegion(region) ?? null; } /** * Creates a default header or footer when none exists. - * - * This method is called when a user double-clicks a header/footer region - * but no content exists yet. It uses the converter API to create an empty - * header/footer document. - * - * @param region - The header/footer region containing kind ('header' | 'footer') - * and sectionType ('default' | 'first' | 'even' | 'odd') information - * - * Side effects: - * - Calls converter.createDefaultHeader() or converter.createDefaultFooter() to - * create a new header/footer document in the underlying document model - * - Updates this.#headerFooterIdentifier with the new header/footer IDs from - * the converter after creation - * - * Behavior when converter is unavailable: - * - Returns early without creating any header/footer if converter is not attached - * - Returns early if the appropriate create method is not available on the converter + * Delegates to HeaderFooterSessionManager which handles converter API calls. */ #createDefaultHeaderFooter(region: HeaderFooterRegion): void { - const converter = (this.#editor as EditorWithConverter).converter; - - if (!converter) { - return; - } - - const variant = region.sectionType ?? 'default'; - - if (region.kind === 'header' && typeof converter.createDefaultHeader === 'function') { - converter.createDefaultHeader(variant); - } else if (region.kind === 'footer' && typeof converter.createDefaultFooter === 'function') { - converter.createDefaultFooter(variant); - } - - // Update legacy identifier for getHeaderFooterType() fallback path - if (this.#headerFooterSession) { - this.#headerFooterSession.headerFooterIdentifier = extractIdentifierFromConverter(converter); - } + this.#headerFooterSession?.createDefault(region); } /** @@ -4641,18 +4127,10 @@ export class PresentationEditor extends EventEmitter { /** * Get the page height for the current header/footer context. - * Returns the actual layout height from the header/footer context, or falls back to 1 if unavailable. - * Used for correct coordinate mapping when rendering selections in header/footer mode. + * Delegates to HeaderFooterSessionManager which handles context lookup and fallbacks. */ #getHeaderFooterPageHeight(): number { - const context = this.#getHeaderFooterContext(); - if (!context) { - // Fallback to 1 if context is missing (should rarely happen) - console.warn('[PresentationEditor] Header/footer context missing when computing page height'); - return 1; - } - // Use the actual page height from the header/footer layout - return context.layout.pageSize?.h ?? context.region.height ?? 1; + return this.#headerFooterSession?.getPageHeight() ?? 1; } /** @@ -4703,82 +4181,32 @@ export class PresentationEditor extends EventEmitter { }); } + /** + * Render header/footer hover highlight for a region. + * Delegates to HeaderFooterSessionManager which manages the hover UI elements. + */ #renderHoverRegion(region: HeaderFooterRegion) { - if (this.#documentMode === 'viewing') { - this.#clearHoverRegion(); - return; - } - if (!this.#hoverOverlay || !this.#hoverTooltip) return; - const coords = this.#convertPageLocalToOverlayCoords(region.pageIndex, region.localX, region.localY); - if (!coords) { - this.#clearHoverRegion(); - return; - } - this.#hoverOverlay.style.display = 'block'; - this.#hoverOverlay.style.left = `${coords.x}px`; - this.#hoverOverlay.style.top = `${coords.y}px`; - // Width and height are in layout space - the transform on #selectionOverlay handles scaling - this.#hoverOverlay.style.width = `${region.width}px`; - this.#hoverOverlay.style.height = `${region.height}px`; - - const tooltipText = `Double-click to edit ${region.kind === 'header' ? 'header' : 'footer'}`; - this.#hoverTooltip.textContent = tooltipText; - this.#hoverTooltip.style.display = 'block'; - this.#hoverTooltip.style.left = `${coords.x}px`; - - // Position tooltip above region by default, but below if too close to viewport top - // This prevents clipping for headers at the top of the page - const tooltipHeight = 24; // Approximate tooltip height - const spaceAbove = coords.y; - // Height is in layout space - the transform on #selectionOverlay handles scaling - const regionHeight = region.height; - const tooltipY = - spaceAbove < tooltipHeight + 4 - ? coords.y + regionHeight + 4 // Position below if near top (with 4px spacing) - : coords.y - tooltipHeight; // Position above otherwise - this.#hoverTooltip.style.top = `${Math.max(0, tooltipY)}px`; + this.#headerFooterSession?.renderHover(region); } + /** + * Clear header/footer hover highlight. + * Delegates to HeaderFooterSessionManager which manages the hover UI elements. + */ #clearHoverRegion() { this.#headerFooterSession?.clearHover(); - if (this.#hoverOverlay) { - this.#hoverOverlay.style.display = 'none'; - } - if (this.#hoverTooltip) { - this.#hoverTooltip.style.display = 'none'; - } } #getHeaderFooterContext(): HeaderFooterLayoutContext | null { return this.#headerFooterSession?.getContext() ?? null; } + /** + * Compute selection rectangles in header/footer mode. + * Delegates to HeaderFooterSessionManager which handles context lookup and coordinate transformation. + */ #computeHeaderFooterSelectionRects(from: number, to: number): LayoutRect[] { - const context = this.#getHeaderFooterContext(); - const bodyLayout = this.#layoutState.layout; - if (!context) { - // Warn when header/footer context is unavailable to aid debugging - const session = this.#headerFooterSession?.session; - console.warn('[PresentationEditor] Header/footer context unavailable for selection rects', { - mode: session?.mode, - pageIndex: session?.pageIndex, - }); - return []; - } - if (!bodyLayout) return []; - const rects = selectionToRects(context.layout, context.blocks, context.measures, from, to, undefined) ?? []; - const headerPageHeight = context.layout.pageSize?.h ?? context.region.height ?? 1; - const bodyPageHeight = this.#getBodyPageHeight(); - return rects.map((rect: LayoutRect) => { - const headerLocalY = rect.y - rect.pageIndex * headerPageHeight; - return { - pageIndex: context.region.pageIndex, - x: rect.x + context.region.localX, - y: context.region.pageIndex * bodyPageHeight + context.region.localY + headerLocalY, - width: rect.width, - height: rect.height, - }; - }); + return this.#headerFooterSession?.computeSelectionRects(from, to) ?? []; } #syncTrackedChangesPreferences(): boolean { @@ -5217,9 +4645,7 @@ export class PresentationEditor extends EventEmitter { } #findRegionForPage(kind: 'header' | 'footer', pageIndex: number): HeaderFooterRegion | null { - const map = kind === 'header' ? this.#headerFooterSession?.headerRegions : this.#headerFooterSession?.footerRegions; - if (!map) return null; - return map.get(pageIndex) ?? map.values().next().value ?? null; + return this.#headerFooterSession?.findRegionForPage(kind, pageIndex) ?? null; } #handleLayoutError(phase: LayoutError['phase'], error: Error) { 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 d1d9c3de1..2009bf676 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 @@ -11,7 +11,7 @@ * @module presentation-editor/header-footer/HeaderFooterSessionManager */ -import type { Layout, FlowBlock, Measure, Page, SectionMetadata } from '@superdoc/contracts'; +import type { Layout, FlowBlock, Measure, Page, SectionMetadata, Fragment } from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; import { selectionToRects } from '@superdoc/layout-bridge'; @@ -34,6 +34,10 @@ import { initHeaderFooterRegistry } from '../../header-footer/HeaderFooterRegist import { layoutPerRIdHeaderFooters } from '../../header-footer/HeaderFooterPerRidLayout.js'; import { extractIdentifierFromConverter, + getHeaderFooterType, + getHeaderFooterTypeForSection, + getBucketForPageNumber, + getBucketRepresentative, type HeaderFooterIdentifier, type HeaderFooterLayoutResult, type MultiSectionHeaderFooterIdentifier, @@ -534,6 +538,40 @@ export class HeaderFooterSessionManager { return regionMap.get(pageIndex) ?? null; } + /** + * Find a region for a page, with fallback to first available region. + * Used when we need any region of the given kind, even if not for the specific page. + */ + findRegionForPage(kind: 'header' | 'footer', pageIndex: number): HeaderFooterRegion | null { + const regionMap = kind === 'header' ? this.#headerRegions : this.#footerRegions; + if (!regionMap) return null; + return regionMap.get(pageIndex) ?? regionMap.values().next().value ?? null; + } + + /** + * Resolve the header/footer descriptor for a given region. + * Looks up by headerId first, then by sectionType, then falls back to first descriptor. + */ + resolveDescriptorForRegion(region: HeaderFooterRegion): HeaderFooterDescriptor | null { + const manager = this.#headerFooterManager; + if (!manager) return null; + if (region.headerId) { + const descriptor = manager.getDescriptorById(region.headerId); + if (descriptor) return descriptor; + } + if (region.sectionType) { + const descriptors = manager.getDescriptors(region.kind); + const match = descriptors.find((entry) => entry.variant === region.sectionType); + if (match) return match; + } + const descriptors = manager.getDescriptors(region.kind); + if (!descriptors.length) { + console.warn('[HeaderFooterSessionManager] No descriptor found for region:', region); + return null; + } + return descriptors[0]; + } + #pointInRegion(region: HeaderFooterRegion, x: number, localY: number): boolean { const withinX = x >= region.localX && x <= region.localX + region.width; const withinY = localY >= region.localY && localY <= region.localY + region.height; @@ -1237,6 +1275,200 @@ export class HeaderFooterSessionManager { this.#multiSectionIdentifier = identifier; } + // =========================================================================== + // Decoration Provider Creation + // =========================================================================== + + /** + * Update decoration providers for header and footer. + * Creates new providers based on layout results and sets them on this manager. + */ + updateDecorationProviders(layout: Layout): void { + this.#headerDecorationProvider = this.createDecorationProvider('header', layout); + this.#footerDecorationProvider = this.createDecorationProvider('footer', layout); + this.rebuildRegions(layout); + } + + /** + * Create a decoration provider for header or footer rendering. + */ + createDecorationProvider(kind: 'header' | 'footer', layout: Layout): PageDecorationProvider | undefined { + const results = kind === 'header' ? this.#headerLayoutResults : this.#footerLayoutResults; + const layoutsByRId = kind === 'header' ? this.#headerLayoutsByRId : this.#footerLayoutsByRId; + + if ((!results || results.length === 0) && (!layoutsByRId || layoutsByRId.size === 0)) { + return undefined; + } + + const multiSectionId = this.#multiSectionIdentifier; + const legacyIdentifier = + this.#headerFooterIdentifier ?? + extractIdentifierFromConverter((this.#options.editor as Editor & { converter?: unknown }).converter); + + const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; + const defaultPageSize = this.#options.defaultPageSize; + const defaultMargins = this.#options.defaultMargins; + + // Build section first page map + const sectionFirstPageNumbers = new Map(); + for (const p of layout.pages) { + const idx = p.sectionIndex ?? 0; + if (!sectionFirstPageNumbers.has(idx)) { + sectionFirstPageNumbers.set(idx, p.number); + } + } + + return (pageNumber, pageMargins, page) => { + const sectionIndex = page?.sectionIndex ?? 0; + const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); + const sectionPageNumber = + typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; + const headerFooterType = multiSectionId + ? getHeaderFooterTypeForSection(pageNumber, sectionIndex, multiSectionId, { kind, sectionPageNumber }) + : getHeaderFooterType(pageNumber, legacyIdentifier, { kind }); + + // Resolve section-specific rId using Word's OOXML inheritance model + let sectionRId: string | undefined; + if (page?.sectionRefs && kind === 'header') { + sectionRId = page.sectionRefs.headerRefs?.[headerFooterType as keyof typeof page.sectionRefs.headerRefs]; + if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { + const prevSectionIds = multiSectionId.sectionHeaderIds.get(sectionIndex - 1); + sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; + } + if (!sectionRId && headerFooterType !== 'default') { + sectionRId = page.sectionRefs.headerRefs?.default; + } + } else if (page?.sectionRefs && kind === 'footer') { + sectionRId = page.sectionRefs.footerRefs?.[headerFooterType as keyof typeof page.sectionRefs.footerRefs]; + if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { + const prevSectionIds = multiSectionId.sectionFooterIds.get(sectionIndex - 1); + sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; + } + if (!sectionRId && headerFooterType !== 'default') { + sectionRId = page.sectionRefs.footerRefs?.default; + } + } + + if (!headerFooterType) { + return null; + } + + // PRIORITY 1: Try per-rId layout + if (sectionRId && layoutsByRId.has(sectionRId)) { + const rIdLayout = layoutsByRId.get(sectionRId); + if (!rIdLayout) { + console.warn( + `[HeaderFooterSessionManager] Inconsistent state: layoutsByRId.has('${sectionRId}') returned true but get() returned undefined`, + ); + } else { + const slotPage = this.#findPageForNumber(rIdLayout.layout.pages, pageNumber); + if (slotPage) { + const fragments = slotPage.fragments ?? []; + 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 = + kind === 'footer' ? this.#stripFootnoteReserveFromBottomMargin(margins, page ?? null) : margins; + const box = this.#computeDecorationBox(kind, decorationMargins, pageHeight); + + const rawLayoutHeight = rIdLayout.layout.height ?? 0; + const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0); + + const layoutMinY = rIdLayout.layout.minY ?? 0; + const normalizedFragments = + layoutMinY < 0 ? fragments.map((f) => ({ ...f, y: f.y - layoutMinY })) : fragments; + + return { + fragments: normalizedFragments, + height: metrics.containerHeight, + contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight, + offset: metrics.offset, + marginLeft: box.x, + contentWidth: box.width, + 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 }, + }; + } + } + } + + // PRIORITY 2: Fall back to variant-based layout + if (!results || results.length === 0) { + return null; + } + + const variant = results.find((entry) => entry.type === headerFooterType); + if (!variant || !variant.layout?.pages?.length) { + return null; + } + + const slotPage = this.#findPageForNumber(variant.layout.pages, pageNumber); + if (!slotPage) { + return null; + } + const fragments = slotPage.fragments ?? []; + + 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 = + kind === 'footer' ? this.#stripFootnoteReserveFromBottomMargin(margins, page ?? null) : margins; + const box = this.#computeDecorationBox(kind, decorationMargins, pageHeight); + + const rawLayoutHeight = variant.layout.height ?? 0; + const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0); + const fallbackId = this.#headerFooterManager?.getVariantId(kind, headerFooterType); + const finalHeaderId = sectionRId ?? fallbackId ?? undefined; + + const layoutMinY = variant.layout.minY ?? 0; + const normalizedFragments = layoutMinY < 0 ? fragments.map((f) => ({ ...f, y: f.y - layoutMinY })) : fragments; + + return { + fragments: normalizedFragments, + height: metrics.containerHeight, + contentHeight: metrics.layoutHeight > 0 ? metrics.layoutHeight : metrics.containerHeight, + offset: metrics.offset, + marginLeft: box.x, + contentWidth: box.width, + headerId: finalHeaderId, + 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 }, + }; + }; + } + + /** + * Find header/footer page layout for a given page number with bucket fallback. + */ + #findPageForNumber( + pages: Array<{ number: number; fragments: Fragment[] }>, + pageNumber: number, + ): { number: number; fragments: Fragment[] } | undefined { + if (!pages || pages.length === 0) { + return undefined; + } + + // 1. Try exact match + const exactMatch = pages.find((p) => p.number === pageNumber); + if (exactMatch) { + return exactMatch; + } + + // 2. Try bucket representative + const bucket = getBucketForPageNumber(pageNumber); + const representative = getBucketRepresentative(bucket); + const bucketMatch = pages.find((p) => p.number === representative); + if (bucketMatch) { + return bucketMatch; + } + + // 3. Fallback to first page + return pages[0]; + } + // =========================================================================== // Cleanup // =========================================================================== From 17bfd0b9959e2258b20e3bd7ce33dd60e73d28b6 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 16 Jan 2026 18:17:04 -0800 Subject: [PATCH 2/3] refactor(presentation-editor): extract FootnotesBuilder from PresentationEditor --- .../presentation-editor/PresentationEditor.ts | 167 +-------- .../layout/FootnotesBuilder.ts | 313 +++++++++++++++++ .../tests/FootnotesBuilder.test.ts | 318 ++++++++++++++++++ 3 files changed, 637 insertions(+), 161 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/layout/FootnotesBuilder.ts create mode 100644 packages/super-editor/src/core/presentation-editor/tests/FootnotesBuilder.test.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 814f128e5..69815ff6e 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -20,6 +20,7 @@ import { normalizeClientPoint as normalizeClientPointFromPointer } from './dom/P import { getPageElementByIndex } from './dom/PageDom.js'; import { inchesToPx, parseColumns } from './layout/LayoutOptionParsing.js'; import { createLayoutMetrics as createLayoutMetricsFromHelper } from './layout/PresentationLayoutMetrics.js'; +import { buildFootnotesInput } from './layout/FootnotesBuilder.js'; import { safeCleanup } from './utils/SafeCleanup.js'; import { createHiddenHost } from './dom/HiddenHost.js'; import { RemoteCursorManager, type RenderDependencies } from './remote-cursors/RemoteCursorManager.js'; @@ -2808,10 +2809,12 @@ export class PresentationEditor extends EventEmitter { this.#applyHtmlAnnotationMeasurements(blocks); const baseLayoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata); - const footnotesLayoutInput = this.#buildFootnotesLayoutInput({ + const footnotesLayoutInput = buildFootnotesInput( + this.#editor?.state, + (this.#editor as EditorWithConverter)?.converter, converterContext, - themeColors: this.#editor?.converter?.themeColors ?? undefined, - }); + this.#editor?.converter?.themeColors ?? undefined, + ); const layoutOptions = footnotesLayoutInput ? { ...baseLayoutOptions, footnotes: footnotesLayoutInput } : baseLayoutOptions; @@ -3438,164 +3441,6 @@ export class PresentationEditor extends EventEmitter { }; } - #buildFootnotesLayoutInput({ - converterContext, - themeColors, - }: { - converterContext: ConverterContext | undefined; - themeColors: unknown; - }): FootnotesLayoutInput | null { - const footnoteNumberById = converterContext?.footnoteNumberById; - - const toSuperscriptDigits = (value: unknown): string => { - const map: Record = { - '0': '⁰', - '1': '¹', - '2': '²', - '3': '³', - '4': '⁴', - '5': '⁵', - '6': '⁶', - '7': '⁷', - '8': '⁸', - '9': '⁹', - }; - const str = String(value ?? ''); - return str - .split('') - .map((ch) => map[ch] ?? ch) - .join(''); - }; - - const ensureFootnoteMarker = (blocks: FlowBlock[], id: string): void => { - const displayNumberRaw = - footnoteNumberById && typeof footnoteNumberById === 'object' ? footnoteNumberById[id] : undefined; - const displayNumber = - typeof displayNumberRaw === 'number' && Number.isFinite(displayNumberRaw) && displayNumberRaw > 0 - ? displayNumberRaw - : 1; - const firstParagraph = blocks.find((b) => b?.kind === 'paragraph') as - | (FlowBlock & { kind: 'paragraph'; runs?: Array> }) - | undefined; - if (!firstParagraph) return; - const runs = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; - const markerText = toSuperscriptDigits(displayNumber); - - const baseRun = runs.find((r) => { - const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; - if (dataAttrs?.['data-sd-footnote-number']) return false; - const pmStart = (r as { pmStart?: unknown }).pmStart; - const pmEnd = (r as { pmEnd?: unknown }).pmEnd; - return ( - typeof pmStart === 'number' && Number.isFinite(pmStart) && typeof pmEnd === 'number' && Number.isFinite(pmEnd) - ); - }) as { pmStart: number; pmEnd: number } | undefined; - - const markerPmStart = baseRun?.pmStart ?? null; - const markerPmEnd = - markerPmStart != null - ? baseRun?.pmEnd != null - ? Math.max(markerPmStart, Math.min(baseRun.pmEnd, markerPmStart + markerText.length)) - : markerPmStart + markerText.length - : null; - - const alreadyHasMarker = runs.some((r) => { - const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; - return Boolean(dataAttrs?.['data-sd-footnote-number']); - }); - if (alreadyHasMarker) { - if (markerPmStart != null && markerPmEnd != null) { - const markerRun = runs.find((r) => { - const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; - return Boolean(dataAttrs?.['data-sd-footnote-number']); - }) as { pmStart?: number | null; pmEnd?: number | null } | undefined; - if (markerRun) { - if (markerRun.pmStart == null) markerRun.pmStart = markerPmStart; - if (markerRun.pmEnd == null) markerRun.pmEnd = markerPmEnd; - } - } - return; - } - - const firstTextRun = runs.find((r) => typeof (r as { text?: unknown }).text === 'string') as - | { fontFamily?: unknown; fontSize?: unknown; color?: unknown; text?: unknown } - | undefined; - - const markerRun: Record = { - kind: 'text', - text: markerText, - dataAttrs: { - 'data-sd-footnote-number': 'true', - }, - ...(markerPmStart != null ? { pmStart: markerPmStart } : {}), - ...(markerPmEnd != null ? { pmEnd: markerPmEnd } : {}), - }; - markerRun.fontFamily = typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : 'Arial'; - markerRun.fontSize = - typeof firstTextRun?.fontSize === 'number' && Number.isFinite(firstTextRun.fontSize) - ? firstTextRun.fontSize - : 12; - if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; - - // Insert marker at the very start. - runs.unshift(markerRun); - - firstParagraph.runs = runs; - }; - - const state = this.#editor?.state; - if (!state) return null; - - const converter = (this.#editor as Partial)?.converter; - const importedFootnotes = Array.isArray(converter?.footnotes) ? converter.footnotes : []; - if (importedFootnotes.length === 0) return null; - - const refs: FootnoteReference[] = []; - const idsInUse = new Set(); - state.doc.descendants((node, pos) => { - if (node.type?.name !== 'footnoteReference') return; - const id = node.attrs?.id; - if (id == null) return; - const key = String(id); - const insidePos = Math.min(pos + 1, state.doc.content.size); - refs.push({ id: key, pos: insidePos }); - idsInUse.add(key); - }); - if (refs.length === 0) return null; - - const blocksById = new Map(); - idsInUse.forEach((id) => { - const entry = importedFootnotes.find((f) => String(f?.id) === id); - const content = entry?.content; - if (!Array.isArray(content) || content.length === 0) return; - - try { - const clonedContent = JSON.parse(JSON.stringify(content)); - const footnoteDoc = { type: 'doc', content: clonedContent }; - const result = toFlowBlocks(footnoteDoc, { - blockIdPrefix: `footnote-${id}-`, - enableRichHyperlinks: true, - themeColors: themeColors as never, - converterContext: converterContext as never, - }); - if (result?.blocks?.length) { - ensureFootnoteMarker(result.blocks, id); - blocksById.set(id, result.blocks); - } - } catch {} - }); - - if (blocksById.size === 0) return null; - - return { - refs, - blocksById, - gap: 2, - topPadding: 4, - dividerHeight: 1, - }; - } - #buildHeaderFooterInput() { const adapter = this.#headerFooterSession?.adapter; if (!adapter) { diff --git a/packages/super-editor/src/core/presentation-editor/layout/FootnotesBuilder.ts b/packages/super-editor/src/core/presentation-editor/layout/FootnotesBuilder.ts new file mode 100644 index 000000000..f20a804ee --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/layout/FootnotesBuilder.ts @@ -0,0 +1,313 @@ +/** + * FootnotesBuilder - Builds footnote layout input from editor state. + * + * No external side effects, no DOM access, no callbacks. + * Note: Mutates the blocks passed to ensureFootnoteMarker internally. + * + * ## Key Concepts + * + * - `pmStart`/`pmEnd`: ProseMirror document positions that map layout elements + * back to their source positions in the editor. Used for selection, cursor + * placement, and click-to-position functionality. + * + * - `data-sd-footnote-number`: A data attribute marking the superscript number + * run (e.g., "¹") at the start of footnote content. Used to distinguish the + * marker from actual footnote text during rendering and selection. + * + * @module presentation-editor/layout/FootnotesBuilder + */ + +import type { EditorState } from 'prosemirror-state'; +import type { FlowBlock } from '@superdoc/contracts'; +import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter'; + +import type { FootnoteReference, FootnotesLayoutInput } from '../types.js'; + +// Re-export types for consumers +export type { FootnoteReference, FootnotesLayoutInput }; + +// ============================================================================= +// Types +// ============================================================================= + +/** Minimal shape of a converter object containing footnote data. */ +export type ConverterLike = { + footnotes?: Array<{ id?: unknown; content?: unknown[] }>; +}; + +/** A text run within a paragraph block. */ +type Run = { + kind?: string; + text?: string; + fontFamily?: string; + fontSize?: number; + color?: unknown; + pmStart?: number | null; + pmEnd?: number | null; + dataAttrs?: Record; +}; + +/** Paragraph block with typed runs array. */ +type ParagraphBlock = FlowBlock & { + kind: 'paragraph'; + runs?: Run[]; +}; + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Builds footnote layout input from editor state and converter data. + * + * Traverses the document to find footnote references, then builds layout + * blocks for each referenced footnote with superscript markers prepended. + * + * No external side effects, no DOM access, no callbacks. + * Note: Mutates blocks internally when adding footnote markers. + * + * @param editorState - The ProseMirror editor state + * @param converter - Converter with footnote data + * @param converterContext - Context with footnote numbering info + * @param themeColors - Theme colors for styling + * @returns FootnotesLayoutInput if footnotes exist, null otherwise + */ +export function buildFootnotesInput( + editorState: EditorState | null | undefined, + converter: ConverterLike | null | undefined, + converterContext: ConverterContext | undefined, + themeColors: unknown, +): FootnotesLayoutInput | null { + if (!editorState) return null; + + const footnoteNumberById = converterContext?.footnoteNumberById; + const importedFootnotes = Array.isArray(converter?.footnotes) ? converter.footnotes : []; + + if (importedFootnotes.length === 0) return null; + + // Find footnote references in the document + const refs: FootnoteReference[] = []; + const idsInUse = new Set(); + + editorState.doc.descendants((node, pos) => { + if (node.type?.name !== 'footnoteReference') return; + const id = node.attrs?.id; + if (id == null) return; + const key = String(id); + // Use pos + 1 to point inside the node rather than at its boundary. + // This ensures cursor placement lands within the footnote reference. + const insidePos = Math.min(pos + 1, editorState.doc.content.size); + refs.push({ id: key, pos: insidePos }); + idsInUse.add(key); + }); + + if (refs.length === 0) return null; + + // Build blocks for each footnote + const blocksById = new Map(); + + idsInUse.forEach((id) => { + const entry = importedFootnotes.find((f) => String(f?.id) === id); + const content = entry?.content; + if (!Array.isArray(content) || content.length === 0) return; + + try { + // Deep clone to prevent mutation of the original converter data + const clonedContent = JSON.parse(JSON.stringify(content)); + const footnoteDoc = { type: 'doc', content: clonedContent }; + const result = toFlowBlocks(footnoteDoc, { + blockIdPrefix: `footnote-${id}-`, + enableRichHyperlinks: true, + themeColors: themeColors as never, + converterContext: converterContext as never, + }); + + if (result?.blocks?.length) { + ensureFootnoteMarker(result.blocks, id, footnoteNumberById); + blocksById.set(id, result.blocks); + } + } catch (_) { + // Skip malformed footnotes - invalid JSON structure or conversion failure + } + }); + + if (blocksById.size === 0) return null; + + return { + refs, + blocksById, + gap: 2, + topPadding: 4, + dividerHeight: 1, + }; +} + +// ============================================================================= +// Internal Helpers +// ============================================================================= + +/** + * Checks if a run is a footnote number marker. + * + * @param run - The run to check + * @returns True if the run has the footnote marker data attribute + */ +function isFootnoteMarker(run: Run): boolean { + return Boolean(run.dataAttrs?.['data-sd-footnote-number']); +} + +/** + * Finds the first run with valid ProseMirror position data. + * Used to inherit position info for the marker run. + * + * @param runs - Array of runs to search + * @returns The first run with pmStart/pmEnd, or undefined + */ +function findRunWithPositions(runs: Run[]): Run | undefined { + return runs.find((r) => { + if (isFootnoteMarker(r)) return false; + return ( + typeof r.pmStart === 'number' && + Number.isFinite(r.pmStart) && + typeof r.pmEnd === 'number' && + Number.isFinite(r.pmEnd) + ); + }); +} + +/** + * Resolves the display number for a footnote. + * Falls back to 1 if the footnote ID is not in the mapping or invalid. + * + * @param id - The footnote ID + * @param footnoteNumberById - Mapping of footnote IDs to display numbers + * @returns The display number (1-based) + */ +function resolveDisplayNumber(id: string, footnoteNumberById: Record | undefined): number { + if (!footnoteNumberById || typeof footnoteNumberById !== 'object') return 1; + const num = footnoteNumberById[id]; + if (typeof num === 'number' && Number.isFinite(num) && num > 0) return num; + return 1; +} + +/** + * Converts digits to their superscript Unicode equivalents. + * Non-digit characters pass through unchanged. + * + * @example + * toSuperscriptDigits(123) // "¹²³" + * toSuperscriptDigits("42") // "⁴²" + * + * @param value - The value to convert (coerced to string) + * @returns String with digits replaced by superscript equivalents + */ +function toSuperscriptDigits(value: unknown): string { + const SUPERSCRIPT_MAP: Record = { + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', + }; + const str = String(value ?? ''); + return str + .split('') + .map((ch) => SUPERSCRIPT_MAP[ch] ?? ch) + .join(''); +} + +/** + * Computes the PM position range for the marker run. + * + * The marker inherits position info from an existing run so that clicking + * on the footnote number positions the cursor correctly. The end position + * is clamped to not exceed the original run's range. + * + * @param baseRun - The run to inherit positions from + * @param markerLength - Length of the marker text + * @returns Object with pmStart and pmEnd, or nulls if no base run + */ +function computeMarkerPositions( + baseRun: Run | undefined, + markerLength: number, +): { pmStart: number | null; pmEnd: number | null } { + if (baseRun?.pmStart == null) { + return { pmStart: null, pmEnd: null }; + } + + const pmStart = baseRun.pmStart; + // Clamp pmEnd to not exceed the base run's end position + const pmEnd = + baseRun.pmEnd != null ? Math.max(pmStart, Math.min(baseRun.pmEnd, pmStart + markerLength)) : pmStart + markerLength; + + return { pmStart, pmEnd }; +} + +/** + * Ensures a footnote block has a superscript marker at the start. + * + * Word and other editors display footnote content with a leading superscript + * number (e.g., "¹ This is the footnote text."). This function prepends that + * marker to the first paragraph's runs. + * + * If a marker already exists, updates its PM positions if missing. + * Modifies the blocks array in place. + * + * @param blocks - Array of FlowBlocks to modify + * @param id - The footnote ID + * @param footnoteNumberById - Mapping of footnote IDs to display numbers + */ +function ensureFootnoteMarker( + blocks: FlowBlock[], + id: string, + footnoteNumberById: Record | undefined, +): void { + const firstParagraph = blocks.find((b) => b?.kind === 'paragraph') as ParagraphBlock | undefined; + if (!firstParagraph) return; + + const runs: Run[] = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; + const displayNumber = resolveDisplayNumber(id, footnoteNumberById); + const markerText = toSuperscriptDigits(displayNumber); + + const baseRun = findRunWithPositions(runs); + const { pmStart, pmEnd } = computeMarkerPositions(baseRun, markerText.length); + + // Check if marker already exists + const existingMarker = runs.find(isFootnoteMarker); + if (existingMarker) { + // Update position info on existing marker if missing + if (pmStart != null && pmEnd != null) { + if (existingMarker.pmStart == null) existingMarker.pmStart = pmStart; + if (existingMarker.pmEnd == null) existingMarker.pmEnd = pmEnd; + } + return; + } + + // Find first text run to inherit font styling from + const firstTextRun = runs.find((r) => typeof r.text === 'string'); + + // Build the marker run + const markerRun: Run = { + kind: 'text', + text: markerText, + dataAttrs: { 'data-sd-footnote-number': 'true' }, + fontFamily: typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : 'Arial', + fontSize: + typeof firstTextRun?.fontSize === 'number' && Number.isFinite(firstTextRun.fontSize) ? firstTextRun.fontSize : 12, + }; + + if (pmStart != null) markerRun.pmStart = pmStart; + if (pmEnd != null) markerRun.pmEnd = pmEnd; + if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; + + // Insert marker at the very start of runs + runs.unshift(markerRun); + // Cast needed: local Run type is structurally compatible but not identical + // to the FlowBlock's Run type from @superdoc/contracts + (firstParagraph as { runs: Run[] }).runs = runs; +} diff --git a/packages/super-editor/src/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/core/presentation-editor/tests/FootnotesBuilder.test.ts new file mode 100644 index 000000000..60834447f --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { EditorState } from 'prosemirror-state'; +import { buildFootnotesInput, type ConverterLike } from '../layout/FootnotesBuilder.js'; +import type { ConverterContext } from '@superdoc/pm-adapter'; + +// Mock toFlowBlocks +vi.mock('@superdoc/pm-adapter', () => ({ + toFlowBlocks: vi.fn((_doc: unknown, opts?: { blockIdPrefix?: string }) => { + // Return mock blocks based on blockIdPrefix + if (typeof opts?.blockIdPrefix === 'string') { + const id = opts.blockIdPrefix.replace('footnote-', '').replace('-', ''); + return { + blocks: [ + { + kind: 'paragraph', + runs: [{ kind: 'text', text: `Footnote ${id} text`, pmStart: 0, pmEnd: 10 }], + }, + ], + bookmarks: new Map(), + }; + } + return { blocks: [], bookmarks: new Map() }; + }), +})); + +// ============================================================================= +// Test Helpers +// ============================================================================= + +function createMockEditorState(refs: Array<{ id: string; pos: number }>): EditorState { + return { + doc: { + content: { size: 1000 }, + descendants: (callback: (node: unknown, pos: number) => boolean | void) => { + refs.forEach(({ id, pos }) => { + callback({ type: { name: 'footnoteReference' }, attrs: { id } }, pos); + }); + return false; + }, + }, + } as unknown as EditorState; +} + +function createMockConverter(footnotes: Array<{ id: string; content: unknown[] }>): ConverterLike { + return { footnotes }; +} + +function createMockConverterContext(footnoteNumberById: Record): ConverterContext { + return { footnoteNumberById } as ConverterContext; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('buildFootnotesInput', () => { + describe('null/undefined inputs', () => { + it('returns null when editorState is null', () => { + const converter = createMockConverter([{ id: '1', content: [{ type: 'paragraph' }] }]); + const result = buildFootnotesInput(null, converter, undefined, undefined); + expect(result).toBeNull(); + }); + + it('returns null when editorState is undefined', () => { + const converter = createMockConverter([{ id: '1', content: [{ type: 'paragraph' }] }]); + const result = buildFootnotesInput(undefined, converter, undefined, undefined); + expect(result).toBeNull(); + }); + + it('returns null when converter is null', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const result = buildFootnotesInput(editorState, null, undefined, undefined); + expect(result).toBeNull(); + }); + + it('returns null when converter is undefined', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const result = buildFootnotesInput(editorState, undefined, undefined, undefined); + expect(result).toBeNull(); + }); + }); + + describe('empty footnotes', () => { + it('returns null when converter has no footnotes', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([]); + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + expect(result).toBeNull(); + }); + + it('returns null when converter.footnotes is not an array', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = { footnotes: 'not an array' } as unknown as ConverterLike; + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + expect(result).toBeNull(); + }); + + it('returns null when document has no footnote references', () => { + const editorState = createMockEditorState([]); + const converter = createMockConverter([{ id: '1', content: [{ type: 'paragraph' }] }]); + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + expect(result).toBeNull(); + }); + }); + + describe('successful builds', () => { + it('builds input with refs and blocks when footnotes exist', () => { + const editorState = createMockEditorState([ + { id: '1', pos: 10 }, + { id: '2', pos: 20 }, + ]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note 1' }] }] }, + { id: '2', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note 2' }] }] }, + ]); + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + expect(result).not.toBeNull(); + expect(result?.refs).toHaveLength(2); + expect(result?.refs[0]).toEqual({ id: '1', pos: 11 }); // pos + 1 + expect(result?.refs[1]).toEqual({ id: '2', pos: 21 }); // pos + 1 + expect(result?.blocksById.size).toBe(2); + expect(result?.blocksById.has('1')).toBe(true); + expect(result?.blocksById.has('2')).toBe(true); + }); + + it('returns correct default spacing values', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + expect(result?.gap).toBe(2); + expect(result?.topPadding).toBe(4); + expect(result?.dividerHeight).toBe(1); + }); + + it('only includes footnotes that are referenced in the document', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); // Only ref 1 in doc + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note 1' }] }] }, + { id: '2', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note 2' }] }] }, + { id: '3', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note 3' }] }] }, + ]); + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + expect(result?.blocksById.size).toBe(1); + expect(result?.blocksById.has('1')).toBe(true); + expect(result?.blocksById.has('2')).toBe(false); + expect(result?.blocksById.has('3')).toBe(false); + }); + }); + + describe('footnote marker insertion', () => { + it('adds superscript marker to footnote content', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + const context = createMockConverterContext({ '1': 1 }); + + const result = buildFootnotesInput(editorState, converter, context, undefined); + + const blocks = result?.blocksById.get('1'); + expect(blocks).toBeDefined(); + expect(blocks?.[0]?.kind).toBe('paragraph'); + + const firstRun = (blocks?.[0] as { runs?: Array<{ text?: string; dataAttrs?: Record }> }) + ?.runs?.[0]; + expect(firstRun?.text).toBe('¹'); + expect(firstRun?.dataAttrs?.['data-sd-footnote-number']).toBe('true'); + }); + + it('uses correct display number from context', () => { + const editorState = createMockEditorState([{ id: '5', pos: 10 }]); + const converter = createMockConverter([ + { id: '5', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + const context = createMockConverterContext({ '5': 3 }); // Display as 3rd footnote + + const result = buildFootnotesInput(editorState, converter, context, undefined); + + const blocks = result?.blocksById.get('5'); + const firstRun = (blocks?.[0] as { runs?: Array<{ text?: string }> })?.runs?.[0]; + expect(firstRun?.text).toBe('³'); + }); + + it('handles multi-digit display numbers', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + const context = createMockConverterContext({ '1': 123 }); + + const result = buildFootnotesInput(editorState, converter, context, undefined); + + const blocks = result?.blocksById.get('1'); + const firstRun = (blocks?.[0] as { runs?: Array<{ text?: string }> })?.runs?.[0]; + expect(firstRun?.text).toBe('¹²³'); + }); + + it('defaults to 1 when footnoteNumberById is missing entry', () => { + const editorState = createMockEditorState([{ id: '99', pos: 10 }]); + const converter = createMockConverter([ + { id: '99', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + const context = createMockConverterContext({}); // No entry for '99' + + const result = buildFootnotesInput(editorState, converter, context, undefined); + + const blocks = result?.blocksById.get('99'); + const firstRun = (blocks?.[0] as { runs?: Array<{ text?: string }> })?.runs?.[0]; + expect(firstRun?.text).toBe('¹'); + }); + + it('defaults to 1 when converterContext is undefined', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + const blocks = result?.blocksById.get('1'); + const firstRun = (blocks?.[0] as { runs?: Array<{ text?: string }> })?.runs?.[0]; + expect(firstRun?.text).toBe('¹'); + }); + }); + + describe('edge cases', () => { + it('handles footnote with empty content array', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverter([{ id: '1', content: [] }]); + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + // Should return null because blocksById would be empty + expect(result).toBeNull(); + }); + + it('handles footnote with null content', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = { footnotes: [{ id: '1', content: null }] } as unknown as ConverterLike; + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + expect(result).toBeNull(); + }); + + it('handles numeric footnote IDs', () => { + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = { footnotes: [{ id: 1, content: [{ type: 'paragraph' }] }] } as unknown as ConverterLike; + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + expect(result).not.toBeNull(); + expect(result?.blocksById.has('1')).toBe(true); + }); + + it('handles duplicate footnote references in document', () => { + const editorState = createMockEditorState([ + { id: '1', pos: 10 }, + { id: '1', pos: 30 }, // Same ID at different position + ]); + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note 1' }] }] }, + ]); + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + expect(result?.refs).toHaveLength(2); + expect(result?.refs[0]).toEqual({ id: '1', pos: 11 }); + expect(result?.refs[1]).toEqual({ id: '1', pos: 31 }); + // But only one entry in blocksById + expect(result?.blocksById.size).toBe(1); + }); + + it('handles footnote ref with null id', () => { + const editorState = { + doc: { + content: { size: 1000 }, + descendants: (callback: (node: unknown, pos: number) => boolean | void) => { + callback({ type: { name: 'footnoteReference' }, attrs: { id: null } }, 10); + return false; + }, + }, + } as unknown as EditorState; + const converter = createMockConverter([{ id: '1', content: [{ type: 'paragraph' }] }]); + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + expect(result).toBeNull(); // No valid refs found + }); + + it('clamps pos to doc content size', () => { + const editorState = { + doc: { + content: { size: 15 }, + descendants: (callback: (node: unknown, pos: number) => boolean | void) => { + callback({ type: { name: 'footnoteReference' }, attrs: { id: '1' } }, 20); // pos > content.size + return false; + }, + }, + } as unknown as EditorState; + const converter = createMockConverter([ + { id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Note' }] }] }, + ]); + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + + expect(result?.refs[0]?.pos).toBe(15); // Clamped to doc.content.size + }); + }); +}); From 076e1a19957745904127c87dae5e47d39e037c07 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 16 Jan 2026 18:37:31 -0800 Subject: [PATCH 3/3] chore: fix header session manager --- .../header-footer/HeaderFooterSessionManager.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 2009bf676..756456a19 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 @@ -1114,7 +1114,13 @@ export class HeaderFooterSessionManager { const converter = (this.#options.editor as EditorWithConverter).converter; const hasAlternateHeaders = converter?.pageStyles?.alternateHeaders === true; - if (isFirstPageOfSection) { + // Only use 'first' variant when titlePg is enabled (w:titlePg element in OOXML). + // Without titlePg, even the first page of a section uses 'default'. + const headerIds = converter?.headerIds as { titlePg?: boolean } | undefined; + const footerIds = converter?.footerIds as { titlePg?: boolean } | undefined; + const titlePgEnabled = headerIds?.titlePg === true || footerIds?.titlePg === true; + + if (isFirstPageOfSection && titlePgEnabled) { return 'first'; } if (hasAlternateHeaders) { @@ -1127,13 +1133,14 @@ export class HeaderFooterSessionManager { margins: HeaderFooterLayoutOptions['margins'], page: Page, ): HeaderFooterLayoutOptions['margins'] { - const footnoteReserve = (page as Page & { footnoteReserve?: number }).footnoteReserve ?? 0; - if (footnoteReserve <= 0) return margins; + // Note: property is 'footnoteReserved' (with 'd') as defined in @superdoc/contracts + const footnoteReserved = page.footnoteReserved ?? 0; + if (footnoteReserved <= 0) return margins; const currentBottom = margins?.bottom ?? this.#options.defaultMargins.bottom ?? 0; return { ...margins, - bottom: Math.max(0, currentBottom - footnoteReserve), + bottom: Math.max(0, currentBottom - footnoteReserved), }; }