Skip to content

Commit 09677dc

Browse files
authored
fix: text selection inside headers/footers (#2404)
* fix: text selection inside headers/footers * fix: address comments * fix: issue with selection in multiple header/footer editors * fix(selection): header/footer selection rect zoom normalization
1 parent 2d38d43 commit 09677dc

4 files changed

Lines changed: 495 additions & 26 deletions

File tree

packages/super-editor/src/core/presentation-editor/PresentationEditor.ts

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,8 @@ export class PresentationEditor extends EventEmitter {
329329
#ariaLiveRegion: HTMLElement | null = null;
330330
#a11ySelectionAnnounceTimeout: number | null = null;
331331
#a11yLastAnnouncedSelectionKey: string | null = null;
332+
#headerFooterSelectionHandler: ((...args: unknown[]) => void) | null = null;
333+
#headerFooterEditor: Editor | null = null;
332334
#lastSelectedFieldAnnotation: {
333335
element: HTMLElement;
334336
pmStart: number;
@@ -2860,7 +2862,6 @@ export class PresentationEditor extends EventEmitter {
28602862
event: 'collaborationReady',
28612863
handler: handleCollaborationReady as (...args: unknown[]) => void,
28622864
});
2863-
28642865
// Listen for comment selection changes to update Layout Engine highlighting
28652866
const handleCommentsUpdate = (payload: { activeCommentId?: string | null }) => {
28662867
if (this.#domPainter?.setActiveComment) {
@@ -3200,11 +3201,41 @@ export class PresentationEditor extends EventEmitter {
32003201
},
32013202
onEditingContext: (data) => {
32023203
this.emit('headerFooterEditingContext', data);
3203-
this.#announce(
3204-
data.kind === 'body'
3205-
? 'Exited header/footer edit mode.'
3206-
: `Editing ${data.kind === 'header' ? 'Header' : 'Footer'} (${data.sectionType ?? 'default'})`,
3207-
);
3204+
3205+
// Clean up any previous header/footer selection listener
3206+
if (this.#headerFooterEditor && this.#headerFooterSelectionHandler) {
3207+
this.#headerFooterEditor.off?.('selectionUpdate', this.#headerFooterSelectionHandler);
3208+
this.#headerFooterEditor = null;
3209+
this.#headerFooterSelectionHandler = null;
3210+
}
3211+
3212+
if (data.kind === 'body') {
3213+
this.#announce('Exited header/footer edit mode.');
3214+
// Ensure the selection overlay is immediately resynced to the body
3215+
// editor when leaving header/footer mode, so any stale header/footer
3216+
// highlights are cleared.
3217+
this.#scheduleSelectionUpdate({ immediate: true });
3218+
} else {
3219+
this.#announce(`Editing ${data.kind === 'header' ? 'Header' : 'Footer'} (${data.sectionType ?? 'default'})`);
3220+
3221+
// Wire selection updates from the active header/footer editor into
3222+
// the shared selection overlay + aria-live announcements.
3223+
const headerFooterEditor = data.editor;
3224+
const handler = () => {
3225+
this.#scheduleSelectionUpdate();
3226+
this.#scheduleA11ySelectionAnnouncement();
3227+
};
3228+
headerFooterEditor.on?.('selectionUpdate', handler);
3229+
this.#headerFooterEditor = headerFooterEditor;
3230+
this.#headerFooterSelectionHandler = handler;
3231+
3232+
// Also trigger an initial selection sync immediately on entry so the
3233+
// body selection overlay is cleared or updated to match the current
3234+
// header/footer selection state, instead of leaving stale body
3235+
// highlights until the first selectionUpdate event fires.
3236+
this.#scheduleSelectionUpdate({ immediate: true });
3237+
this.#scheduleA11ySelectionAnnouncement({ immediate: true });
3238+
}
32083239
},
32093240
onEditBlocked: (reason) => {
32103241
this.emit('headerFooterEditBlocked', { reason });
@@ -4296,10 +4327,9 @@ export class PresentationEditor extends EventEmitter {
42964327
const shouldScrollIntoView = this.#shouldScrollSelectionIntoView;
42974328
this.#shouldScrollSelectionIntoView = false;
42984329

4299-
// In header/footer mode, the ProseMirror editor handles its own caret
43004330
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
43014331
if (sessionMode !== 'body') {
4302-
this.#clearSelectedFieldAnnotationClass();
4332+
this.#updateHeaderFooterSelection();
43034333
return;
43044334
}
43054335

@@ -4960,8 +4990,6 @@ export class PresentationEditor extends EventEmitter {
49604990

49614991
#announceSelectionNow(): void {
49624992
if (!this.#ariaLiveRegion) return;
4963-
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
4964-
if (sessionMode !== 'body') return;
49654993
const announcement = computeA11ySelectionAnnouncementFromHelper(this.getActiveEditor().state);
49664994
if (!announcement) return;
49674995

@@ -5957,6 +5985,70 @@ export class PresentationEditor extends EventEmitter {
59575985
}
59585986
}
59595987

5988+
/**
5989+
* Updates the selection overlay while editing headers/footers.
5990+
*
5991+
* Uses header/footer layout data from HeaderFooterSessionManager to compute
5992+
* selection rectangles in layout space, then renders them into the shared
5993+
* selection overlay so selection behaves consistently with body content.
5994+
*
5995+
* Caret rendering is left to the ProseMirror header/footer editor; this
5996+
* overlay only mirrors non-collapsed selections.
5997+
*/
5998+
#updateHeaderFooterSelection() {
5999+
this.#clearSelectedFieldAnnotationClass();
6000+
6001+
if (!this.#localSelectionLayer) {
6002+
return;
6003+
}
6004+
6005+
const activeEditor = this.getActiveEditor();
6006+
const selection = activeEditor?.state?.selection;
6007+
if (!selection) {
6008+
try {
6009+
this.#localSelectionLayer.innerHTML = '';
6010+
} catch {}
6011+
return;
6012+
}
6013+
6014+
const { from, to } = selection;
6015+
6016+
// Let the header/footer ProseMirror editor handle caret rendering.
6017+
if (from === to) {
6018+
try {
6019+
this.#localSelectionLayer.innerHTML = '';
6020+
} catch {}
6021+
return;
6022+
}
6023+
6024+
const rects = this.#computeHeaderFooterSelectionRects(from, to);
6025+
if (!rects.length) {
6026+
return;
6027+
}
6028+
6029+
// Header/footer selection rects are already mapped into body-page
6030+
// coordinates using the body page height and no page gap. To avoid
6031+
// double-applying any gap or using the header/footer layout height, use
6032+
// the body page height here and a zero page gap.
6033+
const pageHeight = this.#getBodyPageHeight();
6034+
const pageGap = 0;
6035+
6036+
try {
6037+
this.#localSelectionLayer.innerHTML = '';
6038+
renderSelectionRects({
6039+
localSelectionLayer: this.#localSelectionLayer,
6040+
rects,
6041+
pageHeight,
6042+
pageGap,
6043+
convertPageLocalToOverlayCoords: (pageIndex, x, y) => this.#convertPageLocalToOverlayCoords(pageIndex, x, y),
6044+
});
6045+
} catch (error) {
6046+
if (process.env.NODE_ENV === 'development') {
6047+
console.warn('[PresentationEditor] Failed to render header/footer selection rects:', error);
6048+
}
6049+
}
6050+
}
6051+
59606052
#dismissErrorBanner() {
59616053
this.#errorBanner?.remove();
59626054
this.#errorBanner = null;

packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts

Lines changed: 117 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
import type { Layout, FlowBlock, Measure, Page, SectionMetadata, Fragment } from '@superdoc/contracts';
1515
import type { PageDecorationProvider } from '@superdoc/painter-dom';
16-
import { selectionToRects } from '@superdoc/layout-bridge';
1716

1817
import type { Editor } from '../../Editor.js';
1918
import 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
/**

packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,6 +1283,20 @@ export class EditorInputManager {
12831283
return;
12841284
}
12851285

1286+
// When editing a header/footer, let the ProseMirror editor inside the
1287+
// overlay handle double-click word/paragraph selection. Do not re-run
1288+
// header/footer hit-testing for double-clicks that occur inside the
1289+
// active editor host.
1290+
const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
1291+
if (sessionMode !== 'body') {
1292+
const activeEditorHost = this.#deps.getHeaderFooterSession()?.overlayManager?.getActiveEditorHost?.();
1293+
const clickedInsideEditorHost =
1294+
activeEditorHost && (activeEditorHost.contains(target as Node) || activeEditorHost === target);
1295+
if (clickedInsideEditorHost) {
1296+
return;
1297+
}
1298+
}
1299+
12861300
const layoutState = this.#deps.getLayoutState();
12871301
if (!layoutState.layout) return;
12881302

0 commit comments

Comments
 (0)