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 @@ -269,6 +269,8 @@ export class PresentationEditor extends EventEmitter {
#selectionOverlay: HTMLElement;
#permissionOverlay: HTMLElement | null = null;
#hiddenHost: HTMLElement;
/** Scroll-isolating wrapper around #hiddenHost. Append/remove this from the DOM. */
#hiddenHostWrapper: HTMLElement;
#layoutOptions: LayoutEngineOptions;
#layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() };
/** Cache for incremental toFlowBlocks conversion */
Expand All @@ -287,6 +289,13 @@ export class PresentationEditor extends EventEmitter {
#pendingMapping: Mapping | null = null;
#isRerendering = false;
#selectionSync = new SelectionSyncCoordinator();
/**
* When true, the next selection render scrolls the caret/selection head into view.
* Only set for user-initiated actions (keyboard/mouse selection, image click, zoom).
* Passive re-renders (virtualization remounts, layout completions, DOM rebuilds) leave
* this unset so they don't fight the user's scroll position.
*/
#shouldScrollSelectionIntoView = false;
#epochMapper = new EpochPositionMapper();
#layoutEpoch = 0;
#htmlAnnotationHeights: Map<string, number> = new Map();
Expand Down Expand Up @@ -583,11 +592,16 @@ export class PresentationEditor extends EventEmitter {
});
this.#visibleHost.appendChild(this.#ariaLiveRegion);

this.#hiddenHost = createHiddenHost(doc, this.#layoutOptions.pageSize?.w ?? DEFAULT_PAGE_SIZE.w);
const { wrapper: hiddenHostWrapper, host: hiddenHost } = createHiddenHost(
doc,
this.#layoutOptions.pageSize?.w ?? DEFAULT_PAGE_SIZE.w,
);
this.#hiddenHostWrapper = hiddenHostWrapper;
this.#hiddenHost = hiddenHost;
if (doc.body) {
doc.body.appendChild(this.#hiddenHost);
doc.body.appendChild(this.#hiddenHostWrapper);
} else {
this.#visibleHost.appendChild(this.#hiddenHost);
this.#visibleHost.appendChild(this.#hiddenHostWrapper);
}

const { layoutEngineOptions: _layoutEngineOptions, element: _element, ...editorOptions } = options;
Expand Down Expand Up @@ -2436,6 +2450,7 @@ export class PresentationEditor extends EventEmitter {
// Notify DomPainter so virtualization accounts for the CSS transform scale
this.#domPainter?.setZoom?.(zoom);
this.emit('zoomChange', { zoom });
this.#shouldScrollSelectionIntoView = true;
this.#scheduleSelectionUpdate();
// Trigger cursor updates on zoom changes
if (this.#remoteCursorManager?.hasRemoteCursors()) {
Expand Down Expand Up @@ -2557,7 +2572,7 @@ export class PresentationEditor extends EventEmitter {
this.#dragDropManager = null;
this.#selectionOverlay?.remove();
this.#painterHost?.remove();
this.#hiddenHost?.remove();
this.#hiddenHostWrapper?.remove();
this.#hoverOverlay = null;
this.#hoverTooltip = null;
this.#modeBanner?.remove();
Expand Down Expand Up @@ -2682,6 +2697,8 @@ export class PresentationEditor extends EventEmitter {
}
};
const handleSelection = () => {
// User-initiated selection change (keyboard, mouse) — scroll caret into view.
this.#shouldScrollSelectionIntoView = true;
// Use immediate rendering for selection-only changes (clicks, arrow keys).
// Without immediate, the render is RAF-deferred — leaving a window where
// a remote collaborator's edit can cancel the pending render via
Expand Down Expand Up @@ -3028,6 +3045,7 @@ export class PresentationEditor extends EventEmitter {
* @returns {void}
*/
#focusEditorAfterImageSelection(): void {
this.#shouldScrollSelectionIntoView = true;
this.#scheduleSelectionUpdate();
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
Expand Down Expand Up @@ -4205,6 +4223,13 @@ export class PresentationEditor extends EventEmitter {
* @private
*/
#updateSelection() {
// Consume the scroll intent before any early returns. Passive re-renders
// (virtualization remounts, layout completions) never set this flag, so
// they won't scroll the viewport to the caret — only real user-initiated
// 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') {
Expand Down Expand Up @@ -4324,6 +4349,9 @@ export class PresentationEditor extends EventEmitter {
console.warn('[PresentationEditor] Failed to render caret overlay:', error);
}
}
if (shouldScrollIntoView) {
this.#scrollActiveEndIntoView(caretLayout.pageIndex);
}
return;
}

Expand Down Expand Up @@ -4366,6 +4394,99 @@ export class PresentationEditor extends EventEmitter {
console.warn('[PresentationEditor] Failed to render selection rects:', error);
}
}

// Scroll to keep the selection head visible (Shift+Arrow across page boundaries).
// Use the head's layout rect to determine the target page.
if (shouldScrollIntoView) {
const head = activeEditor?.view?.state?.selection?.head ?? to;
const headLayout = this.#computeCaretLayoutRect(head);
if (headLayout) {
this.#scrollActiveEndIntoView(headLayout.pageIndex);
}
}
}

/**
* Scrolls the scroll container minimally so that a screen-space rect is visible,
* keeping a small margin (20px) for comfortable viewing. No-ops when the rect
* is already within the visible bounds.
*/
#scrollScreenRectIntoView(screenTop: number, screenBottom: number): void {
const scrollContainer = this.#scrollContainer;
if (!scrollContainer) return;

let containerTop: number;
let containerBottom: number;

if (scrollContainer instanceof Window) {
containerTop = 0;
containerBottom = scrollContainer.innerHeight;
} else {
const r = (scrollContainer as Element).getBoundingClientRect();
containerTop = r.top;
containerBottom = r.bottom;
}

const SCROLL_MARGIN = 20;

if (screenBottom > containerBottom - SCROLL_MARGIN) {
const delta = screenBottom - containerBottom + SCROLL_MARGIN;
if (scrollContainer instanceof Window) {
scrollContainer.scrollBy({ top: delta });
} else {
(scrollContainer as Element).scrollTop += delta;
}
} else if (screenTop < containerTop + SCROLL_MARGIN) {
const delta = containerTop + SCROLL_MARGIN - screenTop;
if (scrollContainer instanceof Window) {
scrollContainer.scrollBy({ top: -delta });
} else {
(scrollContainer as Element).scrollTop -= delta;
}
}
}

/**
* Scrolls the scroll container so the caret or selection head remains visible
* after selection changes. Works for both collapsed (caret) and range selections.
*
* For collapsed selections, uses the rendered caret element's screen position.
* For range selections, uses the rendered selection rect nearest to the head.
*
* If the target page isn't mounted (virtualized), falls back to scrolling the
* page into view to trigger mount; the next selection update handles precise scroll.
*/
#scrollActiveEndIntoView(pageIndex: number): void {
// Check if the target page is mounted before trusting rendered element positions.
const pageIsMounted = !!this.#painterHost.querySelector(`[data-page-index="${pageIndex}"]`);
if (!pageIsMounted) {
this.#scrollPageIntoView(pageIndex);
return;
}

// Try caret element first (collapsed selection)
const caretEl = this.#localSelectionLayer?.querySelector(
'.presentation-editor__selection-caret',
) as HTMLElement | null;
if (caretEl) {
const r = caretEl.getBoundingClientRect();
this.#scrollScreenRectIntoView(r.top, r.bottom);
return;
}

// Range selection: pick the rendered rect nearest the selection head.
// Rects are rendered in document order. head < anchor means the user is
// extending backward (Shift+ArrowUp) → first child. head >= anchor means
// extending forward (Shift+ArrowDown) → last child.
const sel = this.getActiveEditor()?.view?.state?.selection;
const headIsForward = !sel || sel.head >= sel.anchor;
const headRect = (
headIsForward ? this.#localSelectionLayer?.lastElementChild : this.#localSelectionLayer?.firstElementChild
) as HTMLElement | null;
if (headRect) {
const r = headRect.getBoundingClientRect();
this.#scrollScreenRectIntoView(r.top, r.bottom);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
/**
* Creates a hidden host element for the ProseMirror editor.
* Result of creating the hidden ProseMirror editor host.
*
* The hidden host is wrapped in a scroll-isolation container that prevents the
* browser's native caret-tracking scroll from leaking to the page. Without this
* wrapper, when the focused contenteditable has a deep caret position (e.g., end
* of a long document), the browser continuously scrolls the page to reveal it —
* fighting any programmatic scroll the user or virtualization system performs.
*
* @remarks
* The wrapper is the element to insert into the DOM tree. The host is the element
* passed to ProseMirror's Editor as its container.
*/
export type HiddenHostElements = {
/** Outer wrapper — append this to the DOM. Provides scroll isolation via overflow:hidden. */
wrapper: HTMLElement;
/** Inner host — pass this to ProseMirror as the editor container. */
host: HTMLElement;
};

/**
* Creates a hidden host element for the ProseMirror editor, wrapped in a
* scroll-isolating container.
*
* The hidden host contains the actual ProseMirror editor DOM, which provides semantic
* document structure for accessibility (screen readers, keyboard navigation) while being
Expand All @@ -8,37 +29,55 @@
*
* @param doc - The document object to create the element in
* @param widthPx - The width of the hidden host in pixels (should match document width)
* @returns A configured hidden host div element
* @returns The wrapper (for DOM insertion) and the host (for ProseMirror)
*
* @remarks
* - Uses position: fixed with left: -9999px to move off-screen without affecting scroll
* - Uses opacity: 0 (NOT visibility: hidden) to keep content focusable
* - Does NOT set aria-hidden="true" because the editor must remain accessible
* - Sets pointer-events: none and z-index: -1 to prevent interaction
* - Sets user-select: none to prevent text selection in the hidden editor
* - Sets overflow-anchor: none to prevent scroll anchoring issues when content changes
* - The viewport host is aria-hidden, but this host provides semantic structure
* **Scroll isolation (wrapper):**
* - `position: fixed; overflow: hidden; width: 1px; height: 1px` — creates a tiny,
* off-screen scroll container. When the browser's native caret-tracking tries to
* scroll to the focused contenteditable's caret, it adjusts the wrapper's scrollTop
* (a no-op since the wrapper is 1×1) instead of the page's scrollTop.
*
* **Hidden host (inner element):**
* - `position: absolute` inside the wrapper — takes the full document width for accurate
* text measurement while being visually clipped by the wrapper.
* - Inherits invisibility and non-interactivity from the wrapper (`opacity: 0`,
* `pointer-events: none`). Does NOT use `visibility: hidden` — that prevents focusing.
* - Does NOT set `aria-hidden="true"` because the editor must remain accessible.
* - Sets `user-select: none` to prevent text selection in the hidden editor.
* - Sets `overflow-anchor: none` to prevent scroll anchoring issues when content changes.
*/
export function createHiddenHost(doc: Document, widthPx: number): HTMLElement {
export function createHiddenHost(doc: Document, widthPx: number): HiddenHostElements {
// --- Scroll-isolation wrapper ---
const wrapper = doc.createElement('div');
wrapper.className = 'presentation-editor__hidden-host-wrapper';
wrapper.style.setProperty('position', 'fixed');
wrapper.style.setProperty('left', '-9999px');
wrapper.style.setProperty('top', '0');
wrapper.style.setProperty('width', '1px');
wrapper.style.setProperty('height', '1px');
wrapper.style.setProperty('overflow', 'hidden');
wrapper.style.setProperty('opacity', '0');
wrapper.style.setProperty('z-index', '-1');
wrapper.style.setProperty('pointer-events', 'none');

// --- Inner host for ProseMirror ---
const host = doc.createElement('div');
host.className = 'presentation-editor__hidden-host';
host.style.setProperty('position', 'fixed');
host.style.setProperty('left', '-9999px');
host.style.setProperty('position', 'absolute');
host.style.setProperty('left', '0');
host.style.setProperty('top', '0');
// Only set valid (non-negative) width values
if (widthPx >= 0) {
host.style.setProperty('width', `${widthPx}px`);
}
host.style.setProperty('overflow-anchor', 'none');
host.style.setProperty('pointer-events', 'none');
// DO NOT use visibility:hidden - it prevents focusing!
// Instead use opacity:0 and z-index to hide while keeping focusable
host.style.setProperty('opacity', '0');
host.style.setProperty('z-index', '-1');
host.style.setProperty('user-select', 'none');
// DO NOT set aria-hidden="true" on this element.
// This hidden host contains the actual ProseMirror editor which must remain accessible
// to screen readers and keyboard navigation. The viewport (#viewportHost) is aria-hidden
// because it's purely visual, but this editor provides the semantic document structure.
return host;

wrapper.appendChild(host);
return { wrapper, host };
}
Loading
Loading