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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2860,7 +2862,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) {
Expand Down Expand Up @@ -3200,11 +3201,41 @@ 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.');
// 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'})`);

// 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;
Comment thread
VladaHarbour marked this conversation as resolved.

// 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) => {
this.emit('headerFooterEditBlocked', { reason });
Expand Down Expand Up @@ -4296,10 +4327,9 @@ export class PresentationEditor extends EventEmitter {
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;
}

Expand Down Expand Up @@ -4960,8 +4990,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;

Expand Down Expand Up @@ -5957,6 +5985,70 @@ 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;
}

// 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 = '';
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -42,6 +41,7 @@ import {
type HeaderFooterLayoutResult,
type MultiSectionHeaderFooterIdentifier,
} from '@superdoc/layout-bridge';
import { deduplicateOverlappingRects } from '../dom/DomSelectionGeometry.js';

// =============================================================================
// Types
Expand Down Expand Up @@ -827,6 +827,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;
Expand All @@ -849,9 +869,6 @@ export class HeaderFooterSessionManager {
return;
}

// Hide layout selection overlay
this.#overlayManager.hideSelectionOverlay();

this.#activeEditor = editor;
this.#setupActiveEditorEventBridge(editor);
this.#session = {
Expand Down Expand Up @@ -1260,8 +1277,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', {
Expand All @@ -1271,20 +1310,82 @@ 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.
//
// 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 rects = selectionToRects(context.layout, context.blocks, context.measures, from, to, undefined) ?? [];
const headerPageHeight = context.layout.pageSize?.h ?? context.region.height ?? 1;
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) {
// 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 = 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;
}

layoutRects.push({
pageIndex,
x: region.localX + localX,
y: pageIndex * bodyPageHeight + region.localY + localY,
width,
height,
});
}

return layoutRects;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,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;

Expand Down
Loading
Loading