From 0529cc7c0ca2158a461007f56dd3e818331a68ae Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 16 Mar 2026 19:17:13 +0200 Subject: [PATCH 1/4] fix: text selection inside headers/footers --- .../presentation-editor/PresentationEditor.ts | 98 +++++++++++++++++-- .../HeaderFooterSessionManager.ts | 3 - .../pointer-events/EditorInputManager.ts | 14 +++ 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 1af4e1d23c..16293c9b6a 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -329,6 +329,8 @@ export class PresentationEditor extends EventEmitter { #ariaLiveRegion: HTMLElement | null = null; #a11ySelectionAnnounceTimeout: number | null = null; #a11yLastAnnouncedSelectionKey: string | null = null; + #headerFooterSelectionHandler: ((...args: unknown[]) => void) | null = null; + #headerFooterEditor: Editor | null = null; #lastSelectedFieldAnnotation: { element: HTMLElement; pmStart: number; @@ -3154,11 +3156,30 @@ export class PresentationEditor extends EventEmitter { }, onEditingContext: (data) => { this.emit('headerFooterEditingContext', data); - this.#announce( - data.kind === 'body' - ? 'Exited header/footer edit mode.' - : `Editing ${data.kind === 'header' ? 'Header' : 'Footer'} (${data.sectionType ?? 'default'})`, - ); + + // Clean up any previous header/footer selection listener + if (this.#headerFooterEditor && this.#headerFooterSelectionHandler) { + this.#headerFooterEditor.off?.('selectionUpdate', this.#headerFooterSelectionHandler); + this.#headerFooterEditor = null; + this.#headerFooterSelectionHandler = null; + } + + if (data.kind === 'body') { + this.#announce('Exited header/footer edit mode.'); + } else { + this.#announce(`Editing ${data.kind === 'header' ? 'Header' : 'Footer'} (${data.sectionType ?? 'default'})`); + + // Wire selection updates from the active header/footer editor into + // the shared selection overlay + aria-live announcements. + const headerFooterEditor = data.editor; + const handler = () => { + this.#scheduleSelectionUpdate(); + this.#scheduleA11ySelectionAnnouncement(); + }; + headerFooterEditor.on?.('selectionUpdate', handler); + this.#headerFooterEditor = headerFooterEditor; + this.#headerFooterSelectionHandler = handler; + } }, onEditBlocked: (reason) => { this.emit('headerFooterEditBlocked', { reason }); @@ -4229,11 +4250,10 @@ export class PresentationEditor extends EventEmitter { // selection changes (keyboard, mouse, image click, zoom) will. const shouldScrollIntoView = this.#shouldScrollSelectionIntoView; this.#shouldScrollSelectionIntoView = false; - - // In header/footer mode, the ProseMirror editor handles its own caret + const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; if (sessionMode !== 'body') { - this.#clearSelectedFieldAnnotationClass(); + this.#updateHeaderFooterSelection(); return; } @@ -4894,8 +4914,6 @@ export class PresentationEditor extends EventEmitter { #announceSelectionNow(): void { if (!this.#ariaLiveRegion) return; - const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; - if (sessionMode !== 'body') return; const announcement = computeA11ySelectionAnnouncementFromHelper(this.getActiveEditor().state); if (!announcement) return; @@ -5870,6 +5888,66 @@ export class PresentationEditor extends EventEmitter { } } + /** + * Updates the selection overlay while editing headers/footers. + * + * Uses header/footer layout data from HeaderFooterSessionManager to compute + * selection rectangles in layout space, then renders them into the shared + * selection overlay so selection behaves consistently with body content. + * + * Caret rendering is left to the ProseMirror header/footer editor; this + * overlay only mirrors non-collapsed selections. + */ + #updateHeaderFooterSelection() { + this.#clearSelectedFieldAnnotationClass(); + + if (!this.#localSelectionLayer) { + return; + } + + const activeEditor = this.getActiveEditor(); + const selection = activeEditor?.state?.selection; + if (!selection) { + try { + this.#localSelectionLayer.innerHTML = ''; + } catch {} + return; + } + + const { from, to } = selection; + + // Let the header/footer ProseMirror editor handle caret rendering. + if (from === to) { + try { + this.#localSelectionLayer.innerHTML = ''; + } catch {} + return; + } + + const rects = this.#computeHeaderFooterSelectionRects(from, to); + if (!rects.length) { + return; + } + + const pageHeight = this.#headerFooterSession?.getPageHeight() ?? 1; + const pageGap = this.#layoutState.layout?.pageGap ?? 0; + + try { + this.#localSelectionLayer.innerHTML = ''; + renderSelectionRects({ + localSelectionLayer: this.#localSelectionLayer, + rects, + pageHeight, + pageGap, + convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y), + }); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[PresentationEditor] Failed to render header/footer selection rects:', error); + } + } + } + #dismissErrorBanner() { this.#errorBanner?.remove(); this.#errorBanner = null; 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 8ac7a03fb3..74c4d8f7b5 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 @@ -829,9 +829,6 @@ export class HeaderFooterSessionManager { return; } - // Hide layout selection overlay - this.#overlayManager.hideSelectionOverlay(); - this.#activeEditor = editor; this.#session = { mode: region.kind, diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index 39ff20ac36..c9d62fda69 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1233,6 +1233,20 @@ export class EditorInputManager { return; } + // When editing a header/footer, let the ProseMirror editor inside the + // overlay handle double-click word/paragraph selection. Do not re-run + // header/footer hit-testing for double-clicks that occur inside the + // active editor host. + const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + if (sessionMode !== 'body') { + const activeEditorHost = this.#deps.getHeaderFooterSession()?.overlayManager?.getActiveEditorHost?.(); + const clickedInsideEditorHost = + activeEditorHost && (activeEditorHost.contains(target as Node) || activeEditorHost === target); + if (clickedInsideEditorHost) { + return; + } + } + const layoutState = this.#deps.getLayoutState(); if (!layoutState.layout) return; From 6cdc0c9582a372cb0b72a1f457db85df1901c780 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 17 Mar 2026 16:00:09 +0200 Subject: [PATCH 2/4] fix: address comments --- .../presentation-editor/PresentationEditor.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 16293c9b6a..33f49aff65 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3166,6 +3166,10 @@ export class PresentationEditor extends EventEmitter { if (data.kind === 'body') { this.#announce('Exited header/footer edit mode.'); + // Ensure the selection overlay is immediately resynced to the body + // editor when leaving header/footer mode, so any stale header/footer + // highlights are cleared. + this.#scheduleSelectionUpdate({ immediate: true }); } else { this.#announce(`Editing ${data.kind === 'header' ? 'Header' : 'Footer'} (${data.sectionType ?? 'default'})`); @@ -3179,6 +3183,13 @@ export class PresentationEditor extends EventEmitter { headerFooterEditor.on?.('selectionUpdate', handler); this.#headerFooterEditor = headerFooterEditor; this.#headerFooterSelectionHandler = handler; + + // Also trigger an initial selection sync immediately on entry so the + // body selection overlay is cleared or updated to match the current + // header/footer selection state, instead of leaving stale body + // highlights until the first selectionUpdate event fires. + this.#scheduleSelectionUpdate({ immediate: true }); + this.#scheduleA11ySelectionAnnouncement({ immediate: true }); } }, onEditBlocked: (reason) => { @@ -4250,7 +4261,7 @@ export class PresentationEditor extends EventEmitter { // selection changes (keyboard, mouse, image click, zoom) will. const shouldScrollIntoView = this.#shouldScrollSelectionIntoView; this.#shouldScrollSelectionIntoView = false; - + const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; if (sessionMode !== 'body') { this.#updateHeaderFooterSelection(); @@ -5929,8 +5940,12 @@ export class PresentationEditor extends EventEmitter { return; } - const pageHeight = this.#headerFooterSession?.getPageHeight() ?? 1; - const pageGap = this.#layoutState.layout?.pageGap ?? 0; + // Header/footer selection rects are already mapped into body-page + // coordinates using the body page height and no page gap. To avoid + // double-applying any gap or using the header/footer layout height, use + // the body page height here and a zero page gap. + const pageHeight = this.#getBodyPageHeight(); + const pageGap = 0; try { this.#localSelectionLayer.innerHTML = ''; From 5c3f27661b068141ecbf5c167c993af6b26ebaaf Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 17 Mar 2026 20:50:57 +0200 Subject: [PATCH 3/4] fix: issue with selection in multiple header/footer editors --- .../presentation-editor/PresentationEditor.ts | 1 - .../HeaderFooterSessionManager.ts | 118 ++++++++++++++++-- 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 33f49aff65..e11bf58e04 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2816,7 +2816,6 @@ export class PresentationEditor extends EventEmitter { event: 'collaborationReady', handler: handleCollaborationReady as (...args: unknown[]) => void, }); - // Listen for comment selection changes to update Layout Engine highlighting const handleCommentsUpdate = (payload: { activeCommentId?: string | null }) => { if (this.#domPainter?.setActiveComment) { 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 74c4d8f7b5..39dbfd9bd4 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 @@ -13,7 +13,6 @@ import type { Layout, FlowBlock, Measure, Page, SectionMetadata, Fragment } from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; -import { selectionToRects } from '@superdoc/layout-bridge'; import type { Editor } from '../../Editor.js'; import type { @@ -42,6 +41,7 @@ import { type HeaderFooterLayoutResult, type MultiSectionHeaderFooterIdentifier, } from '@superdoc/layout-bridge'; +import { deduplicateOverlappingRects } from '../dom/DomSelectionGeometry.js'; // ============================================================================= // Types @@ -807,6 +807,26 @@ export class HeaderFooterSessionManager { editor.setEditable(true); editor.setOptions({ documentMode: 'editing' }); + // Ensure the header/footer editor receives focus on user interaction. + // Without this, subsequent clicks in newly-activated editors may not + // update ProseMirror selection because the view never regains focus. + try { + const editorView = editor.view; + if (editorView && editorHost) { + const focusHandler = () => { + try { + editorView.focus(); + } catch { + // Ignore focus errors; selection updates will still work when possible. + } + }; + editorHost.addEventListener('mousedown', focusHandler); + this.#managerCleanups.push(() => editorHost.removeEventListener('mousedown', focusHandler)); + } + } catch { + // Best-effort: if we can't wire the focus handler, continue without it. + } + // Move caret to end of content try { const doc = editor.state?.doc; @@ -1191,8 +1211,30 @@ export class HeaderFooterSessionManager { /** * Compute selection rectangles in header/footer mode. + * + * This method intentionally does NOT use layout-engine geometry. Header/footer + * editing is driven by a dedicated ProseMirror editor instance mounted inside + * an overlay host. For selection, we rely on the browser's native DOM selection + * rectangles from that editor and then remap them into layout coordinates using + * the current region and body page height. + * + * Selection rectangles are therefore derived from: + * - Native ProseMirror selection → DOM Range → client rects + * - Header/footer region → pageIndex / local offset */ computeSelectionRects(from: number, to: number): LayoutRect[] { + // Guard: must be in header/footer mode with an active editor and region context. + if (this.#session.mode === 'body') { + return []; + } + const activeEditor = this.#activeEditor; + if (!activeEditor?.view) { + return []; + } + + const view = activeEditor.view; + + // Resolve layout context for the active header/footer region. const context = this.getContext(); if (!context) { console.warn('[HeaderFooterSessionManager] Header/footer context unavailable for selection rects', { @@ -1202,20 +1244,70 @@ export class HeaderFooterSessionManager { return []; } + const region = context.region; + const pageIndex = region.pageIndex; + + // Compute DOM-based rectangles local to the editor host. We intentionally + // ignore the numeric from/to arguments and any cached ProseMirror + // selection, and instead rely solely on the live DOM selection inside the + // active header/footer editor. This avoids stale selection state when + // switching between multiple header/footer editors. + const domSelection = view.dom.ownerDocument?.getSelection?.(); + let domRectList: DOMRect[] = []; + + if (domSelection && domSelection.rangeCount > 0) { + for (let i = 0; i < domSelection.rangeCount; i += 1) { + const range = domSelection.getRangeAt(i); + if (!range) continue; + const rangeRects = Array.from(range.getClientRects()) as unknown as DOMRect[]; + domRectList.push(...rangeRects); + } + + // Normalize to a minimal set of rects. Browsers often return both a + // line-box rect and a text-content rect on the same line; without + // deduplication this produces overlapping highlights that look like + // intersecting selections. + domRectList = deduplicateOverlappingRects(domRectList); + } + + if (!domRectList.length) { + return []; + } + + // Map DOM client rects to layout coordinates. + const editorHostRect = (view.dom as HTMLElement).getBoundingClientRect(); const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h; - const rects = selectionToRects(context.layout, context.blocks, context.measures, from, to, undefined) ?? []; - const headerPageHeight = context.layout.pageSize?.h ?? context.region.height ?? 1; + const layoutRects: LayoutRect[] = []; + + for (const clientRect of domRectList) { + // Ignore rects that do not intersect the active editor host. This + // prevents stale DOM selections from other header/footer editors (or the + // body editor) from contributing rectangles when switching between hosts. + const horizontallyOverlaps = clientRect.right > editorHostRect.left && clientRect.left < editorHostRect.right; + const verticallyOverlaps = clientRect.bottom > editorHostRect.top && clientRect.top < editorHostRect.bottom; + if (!horizontallyOverlaps || !verticallyOverlaps) { + continue; + } - 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, - }; - }); + const localX = clientRect.left - editorHostRect.left; + const localY = clientRect.top - editorHostRect.top; + const width = clientRect.width; + const height = clientRect.height; + + if (!Number.isFinite(localX) || !Number.isFinite(localY) || width <= 0 || height <= 0) { + continue; + } + + layoutRects.push({ + pageIndex, + x: region.localX + localX, + y: pageIndex * bodyPageHeight + region.localY + localY, + width, + height, + }); + } + + return layoutRects; } /** From 0c511ed53cb799cd7fbf27c0df4db39562bfd208 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Mar 2026 13:51:03 -0700 Subject: [PATCH 4/4] fix(selection): header/footer selection rect zoom normalization --- .../HeaderFooterSessionManager.ts | 22 +- .../tests/HeaderFooterSessionManager.test.ts | 262 ++++++++++++++++++ 2 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts 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 2899d2580d..3b75a4b305 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 @@ -1341,8 +1341,20 @@ export class HeaderFooterSessionManager { } // Map DOM client rects to layout coordinates. - const editorHostRect = (view.dom as HTMLElement).getBoundingClientRect(); + // + // Range.getClientRects() measures in viewport pixels after PresentationEditor + // applies scale(zoom). Region coordinates, page offsets, and the rest of the + // selection pipeline use unscaled layout coordinates, so the DOM-derived + // deltas and sizes must be converted back out of zoom space here. + const editorDom = view.dom as HTMLElement; + const editorHostRect = editorDom.getBoundingClientRect(); const bodyPageHeight = this.#deps?.getBodyPageHeight() ?? this.#options.defaultPageSize.h; + const layoutOptions = this.#deps?.getLayoutOptions() ?? {}; + const zoom = + typeof layoutOptions.zoom === 'number' && Number.isFinite(layoutOptions.zoom) && layoutOptions.zoom > 0 + ? layoutOptions.zoom + : 1; + const toLayoutUnits = (viewportPixels: number): number => viewportPixels / zoom; const layoutRects: LayoutRect[] = []; for (const clientRect of domRectList) { @@ -1355,10 +1367,10 @@ export class HeaderFooterSessionManager { continue; } - const localX = clientRect.left - editorHostRect.left; - const localY = clientRect.top - editorHostRect.top; - const width = clientRect.width; - const height = clientRect.height; + const localX = toLayoutUnits(clientRect.left - editorHostRect.left); + const localY = toLayoutUnits(clientRect.top - editorHostRect.top); + const width = toLayoutUnits(clientRect.width); + const height = toLayoutUnits(clientRect.height); if (!Number.isFinite(localX) || !Number.isFinite(localY) || width <= 0 || height <= 0) { continue; diff --git a/packages/super-editor/src/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts new file mode 100644 index 0000000000..189f1373fb --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockInitHeaderFooterRegistry } = vi.hoisted(() => ({ + mockInitHeaderFooterRegistry: vi.fn(), +})); + +vi.mock('../../header-footer/HeaderFooterRegistryInit.js', () => ({ + initHeaderFooterRegistry: mockInitHeaderFooterRegistry, +})); + +import type { Editor } from '../../Editor.js'; +import { + HeaderFooterSessionManager, + type SessionManagerDependencies, +} from '../header-footer/HeaderFooterSessionManager.js'; + +function createRect(left: number, top: number, width: number, height: number): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect; +} + +function createMainEditorStub(): Editor { + return { + isEditable: true, + view: { + focus: vi.fn(), + }, + } as unknown as Editor; +} + +function createHeaderFooterEditorStub(editorDom: HTMLElement): Editor { + return { + setEditable: vi.fn(), + setOptions: vi.fn(), + commands: { + setTextSelection: vi.fn(), + }, + state: { + doc: { + content: { + size: 10, + }, + }, + }, + view: { + dom: editorDom, + focus: vi.fn(), + }, + on: vi.fn(), + off: vi.fn(), + } as unknown as Editor; +} + +describe('HeaderFooterSessionManager', () => { + let manager: HeaderFooterSessionManager; + let painterHost: HTMLElement; + let visibleHost: HTMLElement; + let selectionOverlay: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + + painterHost = document.createElement('div'); + visibleHost = document.createElement('div'); + selectionOverlay = document.createElement('div'); + + document.body.appendChild(painterHost); + document.body.appendChild(visibleHost); + document.body.appendChild(selectionOverlay); + }); + + afterEach(() => { + manager?.destroy(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + /** + * Sets up a full manager with an active header region and returns the manager + * ready for `computeSelectionRects` assertions. + * + * The DOM selection mock returns a single rect at (120, 90) with size 200x32, + * and the editor host is at (100, 50) with size 600x120. The header region is + * at localX=40, localY=30 on page 1 with bodyPageHeight=800. + */ + async function setupWithZoom(zoom: number | undefined): Promise { + const pageElement = document.createElement('div'); + pageElement.dataset.pageIndex = '1'; + painterHost.appendChild(pageElement); + + const editorHost = document.createElement('div'); + const editorDom = document.createElement('div'); + editorHost.appendChild(editorDom); + + const headerFooterEditor = createHeaderFooterEditorStub(editorDom); + const descriptor = { id: 'rId-header-default', variant: 'default' }; + + const headerFooterManager = { + getDescriptorById: vi.fn(() => descriptor), + getDescriptors: vi.fn(() => [descriptor]), + ensureEditor: vi.fn(async () => headerFooterEditor), + refresh: vi.fn(), + destroy: vi.fn(), + }; + + const overlayManager = { + showEditingOverlay: vi.fn(() => ({ + success: true, + editorHost, + reason: null, + })), + hideEditingOverlay: vi.fn(), + showSelectionOverlay: vi.fn(), + hideSelectionOverlay: vi.fn(), + setOnDimmingClick: vi.fn(), + getActiveEditorHost: vi.fn(() => editorHost), + destroy: vi.fn(), + }; + + mockInitHeaderFooterRegistry.mockReturnValue({ + overlayManager, + headerFooterIdentifier: null, + headerFooterManager, + headerFooterAdapter: null, + cleanups: [], + }); + + 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, + }, + }); + + const layoutOptions: Record = {}; + if (zoom !== undefined) { + layoutOptions.zoom = zoom; + } + + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => layoutOptions), + getPageElement: vi.fn((pageIndex: number) => (pageIndex === 1 ? pageElement : 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(() => 2), + }; + + manager.setDependencies(deps); + manager.initialize(); + manager.setLayoutResults( + [ + { + kind: 'header', + type: 'default', + layout: { + height: 60, + pages: [{ number: 2, fragments: [] }], + }, + blocks: [], + measures: [], + }, + ], + null, + ); + + const headerRegion = { + kind: 'header' as const, + headerId: 'rId-header-default', + sectionType: 'default', + pageIndex: 1, + pageNumber: 2, + localX: 40, + localY: 30, + width: 500, + height: 60, + }; + manager.headerRegions.set(headerRegion.pageIndex, headerRegion); + + vi.spyOn(editorDom, 'getBoundingClientRect').mockReturnValue(createRect(100, 50, 600, 120)); + vi.spyOn(document, 'getSelection').mockReturnValue({ + rangeCount: 1, + getRangeAt: vi.fn(() => ({ + getClientRects: vi.fn(() => [createRect(120, 90, 200, 32)]), + })), + } as unknown as Selection); + + manager.activateRegion(headerRegion); + await vi.waitFor(() => expect(manager.activeEditor).toBe(headerFooterEditor)); + + return manager; + } + + // DOM selection rect: left=120, top=90, w=200, h=32 + // Editor host rect: left=100, top=50 + // Region: localX=40, localY=30, pageIndex=1, bodyPageHeight=800 + // + // At zoom Z the expected layout rect is: + // x = 40 + (120 - 100) / Z + // y = 1*800 + 30 + (90 - 50) / Z + // width = 200 / Z + // height = 32 / Z + + it('converts DOM selection rects to layout coordinates at zoom=2', async () => { + await setupWithZoom(2); + + expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 50, y: 850, width: 100, height: 16 }]); + }); + + it('applies no conversion at zoom=1', async () => { + await setupWithZoom(1); + + expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); + }); + + it('falls back to zoom=1 when zoom is undefined', async () => { + await setupWithZoom(undefined); + + expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); + }); + + it('falls back to zoom=1 when zoom is 0', async () => { + await setupWithZoom(0); + + expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); + }); + + it('falls back to zoom=1 when zoom is negative', async () => { + await setupWithZoom(-1); + + expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); + }); + + it('falls back to zoom=1 when zoom is NaN', async () => { + await setupWithZoom(NaN); + + expect(manager.computeSelectionRects(1, 10)).toEqual([{ pageIndex: 1, x: 60, y: 870, width: 200, height: 32 }]); + }); +});