1313
1414import type { Layout , FlowBlock , Measure , Page , SectionMetadata , Fragment } from '@superdoc/contracts' ;
1515import type { PageDecorationProvider } from '@superdoc/painter-dom' ;
16- import { selectionToRects } from '@superdoc/layout-bridge' ;
1716
1817import type { Editor } from '../../Editor.js' ;
1918import type {
@@ -42,6 +41,7 @@ import {
4241 type HeaderFooterLayoutResult ,
4342 type MultiSectionHeaderFooterIdentifier ,
4443} from '@superdoc/layout-bridge' ;
44+ import { deduplicateOverlappingRects } from '../dom/DomSelectionGeometry.js' ;
4545
4646// =============================================================================
4747// Types
@@ -827,6 +827,26 @@ export class HeaderFooterSessionManager {
827827 editor . setEditable ( true ) ;
828828 editor . setOptions ( { documentMode : 'editing' } ) ;
829829
830+ // Ensure the header/footer editor receives focus on user interaction.
831+ // Without this, subsequent clicks in newly-activated editors may not
832+ // update ProseMirror selection because the view never regains focus.
833+ try {
834+ const editorView = editor . view ;
835+ if ( editorView && editorHost ) {
836+ const focusHandler = ( ) => {
837+ try {
838+ editorView . focus ( ) ;
839+ } catch {
840+ // Ignore focus errors; selection updates will still work when possible.
841+ }
842+ } ;
843+ editorHost . addEventListener ( 'mousedown' , focusHandler ) ;
844+ this . #managerCleanups. push ( ( ) => editorHost . removeEventListener ( 'mousedown' , focusHandler ) ) ;
845+ }
846+ } catch {
847+ // Best-effort: if we can't wire the focus handler, continue without it.
848+ }
849+
830850 // Move caret to end of content
831851 try {
832852 const doc = editor . state ?. doc ;
@@ -849,9 +869,6 @@ export class HeaderFooterSessionManager {
849869 return ;
850870 }
851871
852- // Hide layout selection overlay
853- this . #overlayManager. hideSelectionOverlay ( ) ;
854-
855872 this . #activeEditor = editor ;
856873 this . #setupActiveEditorEventBridge( editor ) ;
857874 this . #session = {
@@ -1260,8 +1277,30 @@ export class HeaderFooterSessionManager {
12601277
12611278 /**
12621279 * Compute selection rectangles in header/footer mode.
1280+ *
1281+ * This method intentionally does NOT use layout-engine geometry. Header/footer
1282+ * editing is driven by a dedicated ProseMirror editor instance mounted inside
1283+ * an overlay host. For selection, we rely on the browser's native DOM selection
1284+ * rectangles from that editor and then remap them into layout coordinates using
1285+ * the current region and body page height.
1286+ *
1287+ * Selection rectangles are therefore derived from:
1288+ * - Native ProseMirror selection → DOM Range → client rects
1289+ * - Header/footer region → pageIndex / local offset
12631290 */
12641291 computeSelectionRects ( from : number , to : number ) : LayoutRect [ ] {
1292+ // Guard: must be in header/footer mode with an active editor and region context.
1293+ if ( this . #session. mode === 'body' ) {
1294+ return [ ] ;
1295+ }
1296+ const activeEditor = this . #activeEditor;
1297+ if ( ! activeEditor ?. view ) {
1298+ return [ ] ;
1299+ }
1300+
1301+ const view = activeEditor . view ;
1302+
1303+ // Resolve layout context for the active header/footer region.
12651304 const context = this . getContext ( ) ;
12661305 if ( ! context ) {
12671306 console . warn ( '[HeaderFooterSessionManager] Header/footer context unavailable for selection rects' , {
@@ -1271,20 +1310,82 @@ export class HeaderFooterSessionManager {
12711310 return [ ] ;
12721311 }
12731312
1313+ const region = context . region ;
1314+ const pageIndex = region . pageIndex ;
1315+
1316+ // Compute DOM-based rectangles local to the editor host. We intentionally
1317+ // ignore the numeric from/to arguments and any cached ProseMirror
1318+ // selection, and instead rely solely on the live DOM selection inside the
1319+ // active header/footer editor. This avoids stale selection state when
1320+ // switching between multiple header/footer editors.
1321+ const domSelection = view . dom . ownerDocument ?. getSelection ?.( ) ;
1322+ let domRectList : DOMRect [ ] = [ ] ;
1323+
1324+ if ( domSelection && domSelection . rangeCount > 0 ) {
1325+ for ( let i = 0 ; i < domSelection . rangeCount ; i += 1 ) {
1326+ const range = domSelection . getRangeAt ( i ) ;
1327+ if ( ! range ) continue ;
1328+ const rangeRects = Array . from ( range . getClientRects ( ) ) as unknown as DOMRect [ ] ;
1329+ domRectList . push ( ...rangeRects ) ;
1330+ }
1331+
1332+ // Normalize to a minimal set of rects. Browsers often return both a
1333+ // line-box rect and a text-content rect on the same line; without
1334+ // deduplication this produces overlapping highlights that look like
1335+ // intersecting selections.
1336+ domRectList = deduplicateOverlappingRects ( domRectList ) ;
1337+ }
1338+
1339+ if ( ! domRectList . length ) {
1340+ return [ ] ;
1341+ }
1342+
1343+ // Map DOM client rects to layout coordinates.
1344+ //
1345+ // Range.getClientRects() measures in viewport pixels after PresentationEditor
1346+ // applies scale(zoom). Region coordinates, page offsets, and the rest of the
1347+ // selection pipeline use unscaled layout coordinates, so the DOM-derived
1348+ // deltas and sizes must be converted back out of zoom space here.
1349+ const editorDom = view . dom as HTMLElement ;
1350+ const editorHostRect = editorDom . getBoundingClientRect ( ) ;
12741351 const bodyPageHeight = this . #deps?. getBodyPageHeight ( ) ?? this . #options. defaultPageSize . h ;
1275- const rects = selectionToRects ( context . layout , context . blocks , context . measures , from , to , undefined ) ?? [ ] ;
1276- const headerPageHeight = context . layout . pageSize ?. h ?? context . region . height ?? 1 ;
1352+ const layoutOptions = this . #deps?. getLayoutOptions ( ) ?? { } ;
1353+ const zoom =
1354+ typeof layoutOptions . zoom === 'number' && Number . isFinite ( layoutOptions . zoom ) && layoutOptions . zoom > 0
1355+ ? layoutOptions . zoom
1356+ : 1 ;
1357+ const toLayoutUnits = ( viewportPixels : number ) : number => viewportPixels / zoom ;
1358+ const layoutRects : LayoutRect [ ] = [ ] ;
1359+
1360+ for ( const clientRect of domRectList ) {
1361+ // Ignore rects that do not intersect the active editor host. This
1362+ // prevents stale DOM selections from other header/footer editors (or the
1363+ // body editor) from contributing rectangles when switching between hosts.
1364+ const horizontallyOverlaps = clientRect . right > editorHostRect . left && clientRect . left < editorHostRect . right ;
1365+ const verticallyOverlaps = clientRect . bottom > editorHostRect . top && clientRect . top < editorHostRect . bottom ;
1366+ if ( ! horizontallyOverlaps || ! verticallyOverlaps ) {
1367+ continue ;
1368+ }
12771369
1278- return rects . map ( ( rect : LayoutRect ) => {
1279- const headerLocalY = rect . y - rect . pageIndex * headerPageHeight ;
1280- return {
1281- pageIndex : context . region . pageIndex ,
1282- x : rect . x + context . region . localX ,
1283- y : context . region . pageIndex * bodyPageHeight + context . region . localY + headerLocalY ,
1284- width : rect . width ,
1285- height : rect . height ,
1286- } ;
1287- } ) ;
1370+ const localX = toLayoutUnits ( clientRect . left - editorHostRect . left ) ;
1371+ const localY = toLayoutUnits ( clientRect . top - editorHostRect . top ) ;
1372+ const width = toLayoutUnits ( clientRect . width ) ;
1373+ const height = toLayoutUnits ( clientRect . height ) ;
1374+
1375+ if ( ! Number . isFinite ( localX ) || ! Number . isFinite ( localY ) || width <= 0 || height <= 0 ) {
1376+ continue ;
1377+ }
1378+
1379+ layoutRects . push ( {
1380+ pageIndex,
1381+ x : region . localX + localX ,
1382+ y : pageIndex * bodyPageHeight + region . localY + localY ,
1383+ width,
1384+ height,
1385+ } ) ;
1386+ }
1387+
1388+ return layoutRects ;
12881389 }
12891390
12901391 /**
0 commit comments