Skip to content

Commit f7961d7

Browse files
authored
fix(editor): arrow key navigation across page boundaries and auto-scroll (SD-1950) (#2191)
* fix(editor): arrow key navigation across page boundaries and auto-scroll (SD-1950) Fix vertical arrow key navigation that would jump sections or get stuck at page boundaries, and add auto-scroll to keep the caret visible during keyboard navigation. Two bugs in the vertical-navigation extension: 1. ArrowDown would jump thousands of characters when crossing page boundaries because the adjacent line's getBoundingClientRect() returns off-screen coordinates, causing hitTest() to map to the wrong position. 2. ArrowUp would get stuck at certain positions because the adjacent line's DOM center Y falls in a zone where hitTest() maps to the current fragment instead of the adjacent one (fragment boundary misalignment), so the cursor stays at the same position. The fix reads data-pm-start/data-pm-end from the adjacent line element and validates the hit test result against this range. When the hit falls outside the line's PM range (with a tight tolerance of 5), a binary search using computeCaretLayoutRect resolves the correct position at the goal X coordinate within the line. This avoids relying on screen-space hit testing when it produces unreliable results. Additionally adds scrollCaretIntoViewIfNeeded() to PresentationEditor to auto-scroll the viewport after selection changes during keyboard navigation. * fix(editor): improve binary search resilience in vertical navigation Skip unmeasurable positions in resolvePositionAtGoalX instead of breaking the entire search. Positions at inline node boundaries may return null from computeCaretLayoutRect, and breaking falls back to pmStart causing the caret to jump to line start. Also document the LTR assumption in the binary search. * fix(editor): scope auto-scroll to arrow keys and add page-mount guard Address review feedback for SD-1950: - Scope auto-scroll to arrow-key navigation only. Add requestScrollCaretIntoView() public method that the vertical-nav extension calls before dispatch. #scrollCaretIntoViewIfNeeded only runs when this flag is set, preventing unexpected scroll jumps from collab edits, undo, find-and-replace, or other programmatic changes. - Add page-mount guard in #scrollCaretIntoViewIfNeeded. The caret element may exist with estimated coordinates even when the page isn't visible (virtualized). Check for the page element in the DOM before using the caret rect, falling back to scrollPageIntoView otherwise. - Add 7 unit tests for resolvePositionAtGoalX covering: closest match, exact match, goalX before/after all positions, null midpoints (inline node boundaries), all-null positions, and single-position ranges. - Export resolvePositionAtGoalX for direct testing. * fix(editor): revert scroll gating, keep unconditional auto-scroll The flag-based scroll gating (requestScrollCaretIntoView) was over-engineered. The 20px margin check in scrollCaretIntoViewIfNeeded already prevents scrolling when the caret is visible. Scrolling when the caret is off-screen is correct for all selection change sources (arrow keys, undo, collab, find-and-replace). Revert to unconditional scroll after caret rendering. Keep the page-mount guard and resolvePositionAtGoalX tests from the previous commit. * fix(editor): remove page-mount guard from scrollCaretIntoViewIfNeeded The DOM query for page mount status was failing (wrong selector after main merge), causing scrollPageIntoView to fire on every selection update instead of precise scroll. Restore the original guard that only checks caret element existence. * fix(editor): add page-mount guard using painterHost reference Check if the caret's target page is mounted before using the caret element's rect for scroll calculations. The caret overlay can exist with estimated coordinates even when the page is virtualized. Query this.#painterHost (stable class field) for the page element instead of the previous fragile querySelector('.superdoc-container') approach. * test(editor): add integration tests for hit validation and fallback path Add two tests exercising the data-pm-start/data-pm-end validation block in handleKeyDown: - Hit lands within PM range: uses hit test result directly, no fallback - Hit lands outside PM range: triggers resolvePositionAtGoalX fallback, binary search resolves position within the line's range * fix(editor): scroll selection head into view for Shift+Arrow navigation Refactor scroll logic to handle both collapsed and range selections: - Extract #scrollScreenRectIntoView for reusable scroll-bounds check - Replace #scrollCaretIntoViewIfNeeded with #scrollActiveEndIntoView that works for both carets and range selections - For range selections, pick the rendered rect nearest the selection head: first child for backward (Shift+ArrowUp), last child for forward (Shift+ArrowDown) - Call scroll after range selection rendering so Shift+Arrow across page boundaries follows the active end * chore: fix behavior tests * chore: fix pnpm lock * chore: fix pnpm dedupe
1 parent 6473acf commit f7961d7

7 files changed

Lines changed: 1208 additions & 1232 deletions

File tree

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

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ export class PresentationEditor extends EventEmitter {
269269
#selectionOverlay: HTMLElement;
270270
#permissionOverlay: HTMLElement | null = null;
271271
#hiddenHost: HTMLElement;
272+
/** Scroll-isolating wrapper around #hiddenHost. Append/remove this from the DOM. */
273+
#hiddenHostWrapper: HTMLElement;
272274
#layoutOptions: LayoutEngineOptions;
273275
#layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() };
274276
/** Cache for incremental toFlowBlocks conversion */
@@ -287,6 +289,13 @@ export class PresentationEditor extends EventEmitter {
287289
#pendingMapping: Mapping | null = null;
288290
#isRerendering = false;
289291
#selectionSync = new SelectionSyncCoordinator();
292+
/**
293+
* When true, the next selection render scrolls the caret/selection head into view.
294+
* Only set for user-initiated actions (keyboard/mouse selection, image click, zoom).
295+
* Passive re-renders (virtualization remounts, layout completions, DOM rebuilds) leave
296+
* this unset so they don't fight the user's scroll position.
297+
*/
298+
#shouldScrollSelectionIntoView = false;
290299
#epochMapper = new EpochPositionMapper();
291300
#layoutEpoch = 0;
292301
#htmlAnnotationHeights: Map<string, number> = new Map();
@@ -583,11 +592,16 @@ export class PresentationEditor extends EventEmitter {
583592
});
584593
this.#visibleHost.appendChild(this.#ariaLiveRegion);
585594

586-
this.#hiddenHost = createHiddenHost(doc, this.#layoutOptions.pageSize?.w ?? DEFAULT_PAGE_SIZE.w);
595+
const { wrapper: hiddenHostWrapper, host: hiddenHost } = createHiddenHost(
596+
doc,
597+
this.#layoutOptions.pageSize?.w ?? DEFAULT_PAGE_SIZE.w,
598+
);
599+
this.#hiddenHostWrapper = hiddenHostWrapper;
600+
this.#hiddenHost = hiddenHost;
587601
if (doc.body) {
588-
doc.body.appendChild(this.#hiddenHost);
602+
doc.body.appendChild(this.#hiddenHostWrapper);
589603
} else {
590-
this.#visibleHost.appendChild(this.#hiddenHost);
604+
this.#visibleHost.appendChild(this.#hiddenHostWrapper);
591605
}
592606

593607
const { layoutEngineOptions: _layoutEngineOptions, element: _element, ...editorOptions } = options;
@@ -2436,6 +2450,7 @@ export class PresentationEditor extends EventEmitter {
24362450
// Notify DomPainter so virtualization accounts for the CSS transform scale
24372451
this.#domPainter?.setZoom?.(zoom);
24382452
this.emit('zoomChange', { zoom });
2453+
this.#shouldScrollSelectionIntoView = true;
24392454
this.#scheduleSelectionUpdate();
24402455
// Trigger cursor updates on zoom changes
24412456
if (this.#remoteCursorManager?.hasRemoteCursors()) {
@@ -2557,7 +2572,7 @@ export class PresentationEditor extends EventEmitter {
25572572
this.#dragDropManager = null;
25582573
this.#selectionOverlay?.remove();
25592574
this.#painterHost?.remove();
2560-
this.#hiddenHost?.remove();
2575+
this.#hiddenHostWrapper?.remove();
25612576
this.#hoverOverlay = null;
25622577
this.#hoverTooltip = null;
25632578
this.#modeBanner?.remove();
@@ -2682,6 +2697,8 @@ export class PresentationEditor extends EventEmitter {
26822697
}
26832698
};
26842699
const handleSelection = () => {
2700+
// User-initiated selection change (keyboard, mouse) — scroll caret into view.
2701+
this.#shouldScrollSelectionIntoView = true;
26852702
// Use immediate rendering for selection-only changes (clicks, arrow keys).
26862703
// Without immediate, the render is RAF-deferred — leaving a window where
26872704
// a remote collaborator's edit can cancel the pending render via
@@ -3028,6 +3045,7 @@ export class PresentationEditor extends EventEmitter {
30283045
* @returns {void}
30293046
*/
30303047
#focusEditorAfterImageSelection(): void {
3048+
this.#shouldScrollSelectionIntoView = true;
30313049
this.#scheduleSelectionUpdate();
30323050
if (document.activeElement instanceof HTMLElement) {
30333051
document.activeElement.blur();
@@ -4205,6 +4223,13 @@ export class PresentationEditor extends EventEmitter {
42054223
* @private
42064224
*/
42074225
#updateSelection() {
4226+
// Consume the scroll intent before any early returns. Passive re-renders
4227+
// (virtualization remounts, layout completions) never set this flag, so
4228+
// they won't scroll the viewport to the caret — only real user-initiated
4229+
// selection changes (keyboard, mouse, image click, zoom) will.
4230+
const shouldScrollIntoView = this.#shouldScrollSelectionIntoView;
4231+
this.#shouldScrollSelectionIntoView = false;
4232+
42084233
// In header/footer mode, the ProseMirror editor handles its own caret
42094234
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
42104235
if (sessionMode !== 'body') {
@@ -4324,6 +4349,9 @@ export class PresentationEditor extends EventEmitter {
43244349
console.warn('[PresentationEditor] Failed to render caret overlay:', error);
43254350
}
43264351
}
4352+
if (shouldScrollIntoView) {
4353+
this.#scrollActiveEndIntoView(caretLayout.pageIndex);
4354+
}
43274355
return;
43284356
}
43294357

@@ -4366,6 +4394,99 @@ export class PresentationEditor extends EventEmitter {
43664394
console.warn('[PresentationEditor] Failed to render selection rects:', error);
43674395
}
43684396
}
4397+
4398+
// Scroll to keep the selection head visible (Shift+Arrow across page boundaries).
4399+
// Use the head's layout rect to determine the target page.
4400+
if (shouldScrollIntoView) {
4401+
const head = activeEditor?.view?.state?.selection?.head ?? to;
4402+
const headLayout = this.#computeCaretLayoutRect(head);
4403+
if (headLayout) {
4404+
this.#scrollActiveEndIntoView(headLayout.pageIndex);
4405+
}
4406+
}
4407+
}
4408+
4409+
/**
4410+
* Scrolls the scroll container minimally so that a screen-space rect is visible,
4411+
* keeping a small margin (20px) for comfortable viewing. No-ops when the rect
4412+
* is already within the visible bounds.
4413+
*/
4414+
#scrollScreenRectIntoView(screenTop: number, screenBottom: number): void {
4415+
const scrollContainer = this.#scrollContainer;
4416+
if (!scrollContainer) return;
4417+
4418+
let containerTop: number;
4419+
let containerBottom: number;
4420+
4421+
if (scrollContainer instanceof Window) {
4422+
containerTop = 0;
4423+
containerBottom = scrollContainer.innerHeight;
4424+
} else {
4425+
const r = (scrollContainer as Element).getBoundingClientRect();
4426+
containerTop = r.top;
4427+
containerBottom = r.bottom;
4428+
}
4429+
4430+
const SCROLL_MARGIN = 20;
4431+
4432+
if (screenBottom > containerBottom - SCROLL_MARGIN) {
4433+
const delta = screenBottom - containerBottom + SCROLL_MARGIN;
4434+
if (scrollContainer instanceof Window) {
4435+
scrollContainer.scrollBy({ top: delta });
4436+
} else {
4437+
(scrollContainer as Element).scrollTop += delta;
4438+
}
4439+
} else if (screenTop < containerTop + SCROLL_MARGIN) {
4440+
const delta = containerTop + SCROLL_MARGIN - screenTop;
4441+
if (scrollContainer instanceof Window) {
4442+
scrollContainer.scrollBy({ top: -delta });
4443+
} else {
4444+
(scrollContainer as Element).scrollTop -= delta;
4445+
}
4446+
}
4447+
}
4448+
4449+
/**
4450+
* Scrolls the scroll container so the caret or selection head remains visible
4451+
* after selection changes. Works for both collapsed (caret) and range selections.
4452+
*
4453+
* For collapsed selections, uses the rendered caret element's screen position.
4454+
* For range selections, uses the rendered selection rect nearest to the head.
4455+
*
4456+
* If the target page isn't mounted (virtualized), falls back to scrolling the
4457+
* page into view to trigger mount; the next selection update handles precise scroll.
4458+
*/
4459+
#scrollActiveEndIntoView(pageIndex: number): void {
4460+
// Check if the target page is mounted before trusting rendered element positions.
4461+
const pageIsMounted = !!this.#painterHost.querySelector(`[data-page-index="${pageIndex}"]`);
4462+
if (!pageIsMounted) {
4463+
this.#scrollPageIntoView(pageIndex);
4464+
return;
4465+
}
4466+
4467+
// Try caret element first (collapsed selection)
4468+
const caretEl = this.#localSelectionLayer?.querySelector(
4469+
'.presentation-editor__selection-caret',
4470+
) as HTMLElement | null;
4471+
if (caretEl) {
4472+
const r = caretEl.getBoundingClientRect();
4473+
this.#scrollScreenRectIntoView(r.top, r.bottom);
4474+
return;
4475+
}
4476+
4477+
// Range selection: pick the rendered rect nearest the selection head.
4478+
// Rects are rendered in document order. head < anchor means the user is
4479+
// extending backward (Shift+ArrowUp) → first child. head >= anchor means
4480+
// extending forward (Shift+ArrowDown) → last child.
4481+
const sel = this.getActiveEditor()?.view?.state?.selection;
4482+
const headIsForward = !sel || sel.head >= sel.anchor;
4483+
const headRect = (
4484+
headIsForward ? this.#localSelectionLayer?.lastElementChild : this.#localSelectionLayer?.firstElementChild
4485+
) as HTMLElement | null;
4486+
if (headRect) {
4487+
const r = headRect.getBoundingClientRect();
4488+
this.#scrollScreenRectIntoView(r.top, r.bottom);
4489+
}
43694490
}
43704491

43714492
/**
Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
/**
2-
* Creates a hidden host element for the ProseMirror editor.
2+
* Result of creating the hidden ProseMirror editor host.
3+
*
4+
* The hidden host is wrapped in a scroll-isolation container that prevents the
5+
* browser's native caret-tracking scroll from leaking to the page. Without this
6+
* wrapper, when the focused contenteditable has a deep caret position (e.g., end
7+
* of a long document), the browser continuously scrolls the page to reveal it —
8+
* fighting any programmatic scroll the user or virtualization system performs.
9+
*
10+
* @remarks
11+
* The wrapper is the element to insert into the DOM tree. The host is the element
12+
* passed to ProseMirror's Editor as its container.
13+
*/
14+
export type HiddenHostElements = {
15+
/** Outer wrapper — append this to the DOM. Provides scroll isolation via overflow:hidden. */
16+
wrapper: HTMLElement;
17+
/** Inner host — pass this to ProseMirror as the editor container. */
18+
host: HTMLElement;
19+
};
20+
21+
/**
22+
* Creates a hidden host element for the ProseMirror editor, wrapped in a
23+
* scroll-isolating container.
324
*
425
* The hidden host contains the actual ProseMirror editor DOM, which provides semantic
526
* document structure for accessibility (screen readers, keyboard navigation) while being
@@ -8,37 +29,55 @@
829
*
930
* @param doc - The document object to create the element in
1031
* @param widthPx - The width of the hidden host in pixels (should match document width)
11-
* @returns A configured hidden host div element
32+
* @returns The wrapper (for DOM insertion) and the host (for ProseMirror)
1233
*
1334
* @remarks
14-
* - Uses position: fixed with left: -9999px to move off-screen without affecting scroll
15-
* - Uses opacity: 0 (NOT visibility: hidden) to keep content focusable
16-
* - Does NOT set aria-hidden="true" because the editor must remain accessible
17-
* - Sets pointer-events: none and z-index: -1 to prevent interaction
18-
* - Sets user-select: none to prevent text selection in the hidden editor
19-
* - Sets overflow-anchor: none to prevent scroll anchoring issues when content changes
20-
* - The viewport host is aria-hidden, but this host provides semantic structure
35+
* **Scroll isolation (wrapper):**
36+
* - `position: fixed; overflow: hidden; width: 1px; height: 1px` — creates a tiny,
37+
* off-screen scroll container. When the browser's native caret-tracking tries to
38+
* scroll to the focused contenteditable's caret, it adjusts the wrapper's scrollTop
39+
* (a no-op since the wrapper is 1×1) instead of the page's scrollTop.
40+
*
41+
* **Hidden host (inner element):**
42+
* - `position: absolute` inside the wrapper — takes the full document width for accurate
43+
* text measurement while being visually clipped by the wrapper.
44+
* - Inherits invisibility and non-interactivity from the wrapper (`opacity: 0`,
45+
* `pointer-events: none`). Does NOT use `visibility: hidden` — that prevents focusing.
46+
* - Does NOT set `aria-hidden="true"` because the editor must remain accessible.
47+
* - Sets `user-select: none` to prevent text selection in the hidden editor.
48+
* - Sets `overflow-anchor: none` to prevent scroll anchoring issues when content changes.
2149
*/
22-
export function createHiddenHost(doc: Document, widthPx: number): HTMLElement {
50+
export function createHiddenHost(doc: Document, widthPx: number): HiddenHostElements {
51+
// --- Scroll-isolation wrapper ---
52+
const wrapper = doc.createElement('div');
53+
wrapper.className = 'presentation-editor__hidden-host-wrapper';
54+
wrapper.style.setProperty('position', 'fixed');
55+
wrapper.style.setProperty('left', '-9999px');
56+
wrapper.style.setProperty('top', '0');
57+
wrapper.style.setProperty('width', '1px');
58+
wrapper.style.setProperty('height', '1px');
59+
wrapper.style.setProperty('overflow', 'hidden');
60+
wrapper.style.setProperty('opacity', '0');
61+
wrapper.style.setProperty('z-index', '-1');
62+
wrapper.style.setProperty('pointer-events', 'none');
63+
64+
// --- Inner host for ProseMirror ---
2365
const host = doc.createElement('div');
2466
host.className = 'presentation-editor__hidden-host';
25-
host.style.setProperty('position', 'fixed');
26-
host.style.setProperty('left', '-9999px');
67+
host.style.setProperty('position', 'absolute');
68+
host.style.setProperty('left', '0');
2769
host.style.setProperty('top', '0');
28-
// Only set valid (non-negative) width values
2970
if (widthPx >= 0) {
3071
host.style.setProperty('width', `${widthPx}px`);
3172
}
3273
host.style.setProperty('overflow-anchor', 'none');
33-
host.style.setProperty('pointer-events', 'none');
3474
// DO NOT use visibility:hidden - it prevents focusing!
35-
// Instead use opacity:0 and z-index to hide while keeping focusable
36-
host.style.setProperty('opacity', '0');
37-
host.style.setProperty('z-index', '-1');
3875
host.style.setProperty('user-select', 'none');
3976
// DO NOT set aria-hidden="true" on this element.
4077
// This hidden host contains the actual ProseMirror editor which must remain accessible
4178
// to screen readers and keyboard navigation. The viewport (#viewportHost) is aria-hidden
4279
// because it's purely visual, but this editor provides the semantic document structure.
43-
return host;
80+
81+
wrapper.appendChild(host);
82+
return { wrapper, host };
4483
}

0 commit comments

Comments
 (0)