Skip to content

fix(editor): arrow key navigation across page boundaries and auto-scroll (SD-1950)#2191

Open
tupizz wants to merge 1 commit intomainfrom
tadeu/sd-1950-arrow-key-navigation-scroll
Open

fix(editor): arrow key navigation across page boundaries and auto-scroll (SD-1950)#2191
tupizz wants to merge 1 commit intomainfrom
tadeu/sd-1950-arrow-key-navigation-scroll

Conversation

@tupizz
Copy link
Contributor

@tupizz tupizz commented Feb 26, 2026

Summary

Fixes arrow key (↑/↓) navigation in presentation mode that would jump entire sections when moving down across page boundaries, and get stuck in a loop when moving back up. Also adds auto-scroll to keep the caret visible during keyboard navigation.

The Problem

Two distinct bugs in the vertical-navigation extension caused broken cursor movement in multi-page documents:

Bug 1: ArrowDown jumps sections instead of line-by-line

When pressing ArrowDown near a page boundary, the cursor would suddenly jump thousands of characters forward — skipping entire pages. For example, in a 7-page Lorem Ipsum document, pressing ArrowDown at position 3126 would jump to position 6178 (a +3052 character jump), then 9350, then 12378 — each ArrowDown skipping thousands of characters.

Bug 2: ArrowUp gets stuck / loops on the same page

When navigating back up (ArrowUp), the cursor would get stuck at certain positions and refuse to move further. Pressing ArrowUp repeatedly at these positions had zero effect.

Root Cause Analysis

Both bugs originate in the same code path: the vertical-navigation extension's handleKeyDowngetAdjacentLineClientTargetgetHitFromLayoutCoords pipeline.

The extension correctly finds the adjacent line DOM element using findAdjacentLineElement() (which traverses fragments and pages). However, it then uses getBoundingClientRect() on that element to get client-space coordinates, and passes those to hitTest() to map to a ProseMirror position.

Bug 1 root cause: When the adjacent line is on the next page below the viewport (user hasn't scrolled there yet), getBoundingClientRect() returns off-screen coordinates (e.g., clientY=1137 when the viewport bottom is at 882). hitTest() with these off-screen coordinates produces wildly incorrect PM positions, causing the cursor to jump thousands of characters.

Bug 2 root cause: Even when the adjacent line is on-screen (same page, different fragment), the center of its DOM bounding rect can fall in a zone where hitTest() maps to the wrong fragment. This was traced by testing hitTest() at every Y pixel across the adjacent line:

y=215-229: hitTest returns pos 15428 (WRONG — current line's fragment)
y=231-239: hitTest returns pos 15363 (CORRECT — adjacent line)
y=241+:    hitTest returns pos 15428 (WRONG — current line's fragment)

The adjacent line's DOM center is at y ≈ 229.5, which falls right at the boundary where hitTest() returns the wrong fragment. So the "new" position equals the current one, and the cursor doesn't move.

The Fix

Approach: Validate hit test results against the adjacent line's known PM range

Instead of blindly trusting hitTest() results (which are unreliable when coordinates are off-screen or at fragment boundaries), we now:

  1. Read data-pm-start/data-pm-end from the adjacent line's DOM element — these attributes contain the exact PM position range for that line.

  2. Validate the hit test result against this range with a tight tolerance of 5 positions. If the hit falls outside [pmStart - 5, pmEnd + 5], it's considered unreliable.

  3. Fall back to resolvePositionAtGoalX() — a binary search using computeCaretLayoutRect to find the position within the line's PM range whose layout X is closest to the goal X coordinate. This uses the layout engine's position-to-coordinate mapping (which is always accurate regardless of scroll position), requiring only ~7 computeCaretLayoutRect calls for a typical 80-character line (O(log N)).

Why this approach

We considered several alternatives:

  • Scrolling the adjacent line into view first: Would cause visual jank (scroll → measure → set selection → scroll back) and add latency to every arrow key press.
  • Using only the layout engine (no hit test): Would require reimplementing goal-X tracking and character-level position resolution from scratch — the current hitTest() works correctly 95%+ of the time.
  • Increasing the hit test tolerance: Too loose a tolerance (e.g., lineRange) fails to catch the "stuck cursor" case where the hit lands just a few positions past the adjacent line's end.

The chosen approach is minimally invasive: the existing hit test pipeline handles the common case correctly; the validation + fallback only activates when the hit test produces an implausible result. The binary search fallback is efficient and uses the same computeCaretLayoutRect API that the rest of the caret positioning system relies on.

Auto-scroll

Added #scrollCaretIntoViewIfNeeded() to PresentationEditor.ts, called after the caret overlay is rendered. It checks if the caret is outside the scroll container bounds and scrolls minimally (with 20px margin) to keep it visible. Falls back to #scrollPageIntoView() for virtualized pages.

Files Changed

File Change
vertical-navigation.js Added PM range validation, resolvePositionAtGoalX() binary search fallback, pmStart/pmEnd extraction from adjacent line
PresentationEditor.ts Added #scrollCaretIntoViewIfNeeded() for auto-scroll on selection change
scrollCaretIntoView.test.ts 9 unit tests for the scroll-into-view logic

Test plan

  • All 688 test files pass (6646 tests)
  • ArrowDown navigates smoothly through all 7 pages of a long Lorem Ipsum document (pos 2 → 42980)
  • ArrowUp navigates smoothly back through all pages (pos 42980 → 2) with no looping
  • Previously stuck position (15428) now correctly moves past on ArrowUp
  • Round-trip test: 100 ArrowDown + 105 ArrowUp returns to position 2
  • Manual testing with various document lengths and structures
  • Verify no regressions in table cell navigation

…oll (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.
@linear
Copy link

linear bot commented Feb 26, 2026

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 70917fba64

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +3973 to +3977
if (!caretEl) {
// Caret page may not be mounted (virtualized) — scroll page into view
// to trigger mount; next selection update will handle precise scroll.
this.#scrollPageIntoView(caretLayout.pageIndex);
return;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect virtualization using page mount state, not caret presence

This branch assumes a missing caret element is the only signal that the target page is virtualized, but renderCaretOverlay can still create .presentation-editor__selection-caret for an unmounted page via the fallback coordinate path (pageIndex * (pageHeight + pageGap)). In mixed-size documents that fallback Y is not the real page offset, so this method skips #scrollPageIntoView and scrolls using an incorrect caret rect, which can leave ArrowUp/ArrowDown navigation off-screen or jump to the wrong vertical position when crossing virtualized pages.

Useful? React with 👍 / 👎.

Copy link
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tupizz the approach looks solid — validating hit results against known PM ranges and falling back to layout-based resolution addresses the actual failure mechanism, and the auto-scroll covers the second half of the requirement. left a few inline comments, all non-blocking.

Comment on lines +405 to +408
// Can't measure this position — fall back to pmStart
break;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when computeCaretLayoutRect(mid) returns null (e.g. inline node boundary), the whole search stops and returns pmStart — caret jumps to line start. skipping the position keeps the search going:

Suggested change
// Can't measure this position — fall back to pmStart
break;
}
if (!rect || !Number.isFinite(rect.x)) {
// Can't measure this position — skip it
lo = mid + 1;
continue;
}

let bestDist = Infinity;

// Binary search: characters within a single line have monotonically increasing X
let lo = pmStart;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this assumes LTR (X increases with PM position). for RTL text X goes the other way, so the search moves in the wrong direction. bestPos/bestDist tracking limits the damage but it'll be less accurate. worth a // NOTE: assumes LTR comment so it's clearly a known limitation?

* @returns {{ pos: number } | null}
*/
function resolvePositionAtGoalX(editor, pmStart, pmEnd, goalX) {
const presentationEditor = editor.presentationEditor;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no tests for resolvePositionAtGoalX yet — vertical-navigation.test.js already mocks computeCaretLayoutRect, so adding a few cases (null midpoint, goalX at line edges, exact match) should be quick. worth doing?

* This mirrors the implementation to allow direct unit testing without
* bootstrapping the full PresentationEditor.
*/
function scrollCaretIntoViewIfNeeded(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this copies the production logic inline — if the real method changes, these tests still pass on the old version. extracting the scroll function into a shared module (like renderCaretOverlay in LocalSelectionOverlayRendering.ts) would keep them in sync. worth considering?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants