Skip to content

fix: scroll an off-screen caret into the viewport when it is placed#265

Open
maccman wants to merge 4 commits into
masterfrom
claude/caret-viewport-scroll-5d9d12
Open

fix: scroll an off-screen caret into the viewport when it is placed#265
maccman wants to merge 4 commits into
masterfrom
claude/caret-viewport-scroll-5d9d12

Conversation

@maccman

@maccman maccman commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

The virtual caret only displays while the editor is focused, but EditorView.focus() restores the selection with preventScroll, and programmatic setSelection dispatches don't necessarily scroll either. Focusing at the end of a long document therefore drew the caret far below the viewport, invisible until the user scrolled manually.

On iOS the problem is worse: the reveal at focus time runs against the pre-keyboard layout. The keyboard reports its height only once it animates up, and a keyboard-aware host shell (Reflect's mobile shell sizes itself to calc(100dvh - keyboard height)) then shrinks the editor's scroller — dropping an end-of-note caret below the fold again with no signal left to re-scroll it.

Why the app-level shim wasn't enough

Reflect papers over this with useCaretReveal, wired only to the daily stream — and it demonstrably failed there: a double-tap on the Daily tab places the caret at the end of today's note, and on a long note the caret still ended up below the viewport. The day-slide has three competing scroll writers — the shim's reveal pin, the remount's scroll-restore chase, and the single-tap jump-to-top — coordinated only by careful cancellation ordering, and the code comments admit the double-tap's second arrival "often lands while this slide is still chasing its saved offset." If any writer scrolls after the shim's reveal goes quiet (or a first-tap jump-to-top lands after the focus request was already consumed), the caret is stranded with nothing re-pinning it. Fixing this at the app level means winning every such race, forever; the reveal window below corrects errant scrolls after the fact instead.

What changed

Reveal on placement. VirtualCaretView raises a reveal-pending flag when the editor gains focus and when a transaction explicitly places the selection while focused, and consumes it on the next reposition by scrolling the caret's destination into view. The scroll goes through a phantom element placed at the target spot using scrollIntoView({block: 'nearest'}) with a 16px scroll margin: a no-op when the caret is already visible, the minimum distance otherwise, walking nested scrollers and the window for free.

Reveal window (the iOS fix). A focus also opens a 1.5s reveal window during which layout disturbances re-reveal the caret: a ResizeObserver on the layer's ancestor chain (the keyboard shrink lands on whichever element the host sizes against the keyboard, and a shrinking scroller fires no scroll event of its own), visual viewport resizes, content growth via the existing view.dom observer, and stray non-user scrolls — both iOS WebKit nudging toward the newly focused element and a host's own competing scroll writers (a scroll restore, a jump-to-top). User scrolls always lead with a pointerdown or wheel event, which closes the window before their scroll arrives; anything else that scrolls during the window loses to a re-reveal. Each disturbance re-arms the window. It arms even on pointer-driven focus — the tap's own placement is visible, but the keyboard it raises can still push the caret off afterwards.

Visual-viewport clamp. For hosts whose layout does not track the keyboard (the overlay case, e.g. plain mobile Safari), scrollIntoView clamps to the layout viewport and can leave the caret behind the keyboard; the reveal now also nudges the nearest scroller so the caret clears the visual viewport bottom.

Notes for review

  • Only explicit placements count: plugin state counts transactions with selectionSet, so a doc change that merely remaps the caret position (collab/streaming edits above the caret) never yanks the viewport — matching ProseMirror's opt-in scrollIntoView convention. A counter rather than the last transaction's flag, since other plugins append transactions after a selection-setting one within a single dispatch.
  • Stale native selection on focus: right after focus the browser parks the DOM selection at the start of the contenteditable, and ProseMirror restores the real one ~20ms later. The reveal target is measured from editor state (coordsAtPos), never the native selection, so the reveal can't lock onto the wrong spot.
  • Pointer placement never scrolls immediately: a click puts the caret where the user can already see it, and scrolling between pointerdown and the click's selection dispatch would displace where the click lands. A pointer-active flag (cleared in a microtask, since ProseMirror dispatches the click's selection from its own document-level mouseup listener) suppresses the one-shot reveal.
  • Phantom element instead of the caret itself: the caret glides to its destination via a left/top transition, so its own box may still be mid-flight when measured.
  • Hosts not yet laid out: an unmeasurable target keeps the flag raised; the existing ResizeObserver retries once layout settles.
  • Host interaction note: a host that programmatically scrolls away from the caret within ~1.5s of focusing will be fought by the reveal window (by design — that scroll would hide the just-placed caret). Once this ships, Reflect should drop useCaretReveal and its day-slide wiring entirely rather than run both: two revealers pinning simultaneously is harmless but pointless, and the shim's cancel/jump-to-top dance is exactly the kind of competing writer the window now overrides.

Testing

New "viewport reveal" suite in virtual-caret.test.ts: focus landing on an off-screen caret scrolls it into view (window and nested-scroller hosts), a programmatic selection jump to document end scrolls, a doc change that merely remaps the selection does not scroll, nothing scrolls while blurred, the caret re-reveals when the scroller shrinks after focus (the keyboard-raise shape), a stray programmatic scroll right after focus is corrected (the daily-tab double-tap shape), and re-revealing stops once the user takes over. Full suite (1641 tests) passes on Chromium; the caret suite also passes on WebKit. Lint and typecheck clean. The visual-viewport clamp is not covered by tests (browser test viewports have no keyboard overlay); the end-to-end double-tap → keyboard raise flow should be verified on an iOS device before Reflect relies on it.

🤖 Generated with Claude Code

The virtual caret only displays while the editor is focused, but
EditorView.focus() restores the selection with preventScroll, and
programmatic setSelection dispatches do not necessarily scroll either —
so focusing at the end of a long document drew the caret far below the
viewport until the user scrolled.

VirtualCaretView now raises a reveal-pending flag on focus gain and on
selection changes while focused, and consumes it by scrolling the
caret's destination into view via a phantom element with
scrollIntoView({block: 'nearest'}) — a no-op when already visible,
minimal scroll otherwise, walking nested scrollers for free.

The target is measured from editor state, never the native selection:
right after focus the browser parks the DOM selection at the start of
the contenteditable and ProseMirror restores it a beat later. Pointer
placement never scrolls (a click already happens inside the viewport,
and scrolling mid-click would displace it), and an unmeasurable target
keeps the flag raised so the ResizeObserver retries once layout
settles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
meowdown Ready Ready Preview, Comment Jul 8, 2026 5:17pm

@pkg-pr-new

pkg-pr-new Bot commented Jul 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/prosekit/meowdown/@meowdown/core@265
npm i https://pkg.pr.new/prosekit/meowdown/@meowdown/react@265

commit: 73fc388

@maccman maccman requested a review from ocavue July 8, 2026 16:50
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 93.9% 3515 / 3743
🔵 Statements 91.57% 3915 / 4275
🔵 Functions 91.52% 832 / 909
🔵 Branches 85.46% 2335 / 2732
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/extensions/virtual-caret.ts 97.7% 89.83% 100% 99.47% 110, 148, 251, 299, 389
Generated in workflow #1114 for commit 73fc388 by the Vitest Coverage Report Action

maccman and others added 3 commits July 8, 2026 18:00
Trigger the viewport reveal from a plugin-state counter of transactions
that explicitly set the selection, instead of comparing selections
across updates: a doc change elsewhere merely remaps the caret position
and must not yank the viewport back to it, matching ProseMirror's
opt-in scrollIntoView convention. A counter rather than the last
transaction's selectionSet flag, since other plugins append
transactions after a selection-setting one within a single dispatch.

Also rename the flag to revealPending to match the reveal vocabulary,
and cover the nested-scroller host (the primary production shape) and
the mapped-selection no-scroll case in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reveal at focus time runs against the pre-keyboard layout: iOS
reports the keyboard height only once it animates up, and a
keyboard-aware host shell (Reflect's mobile shell sizes itself to
calc(100dvh - keyboard height)) then shrinks the editor's scroller —
dropping an end-of-note caret below the fold with no signal the caret
previously watched. Long notes also keep growing after focus as images
size in.

A focus now opens a 1.5s reveal window during which layout disturbances
re-reveal the caret: a ResizeObserver on the layer's ancestor chain
(the keyboard shrink lands on whichever element the host sizes against
the keyboard, and a shrinking scroller fires no scroll event), visual
viewport resizes, content growth via the existing view.dom observer,
and stray non-user scrolls (iOS WebKit nudging toward the newly
focused element). Each disturbance re-arms the window; a pointerdown
or wheel anywhere closes it, since user scrolls always lead with one.
The window also arms on pointer-driven focus — the tap's own placement
is visible, but the keyboard it raises can still push the caret off.

For hosts whose layout does not track the keyboard (the overlay case),
the reveal now also clamps against the visual viewport, nudging the
nearest scroller so the caret clears the keyboard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The daily-tab double-tap bug shape: focus lands at the end of a long
note, then a competing host scroll writer (a remount's scroll restore,
a jump-to-top) scrolls away from the just-placed caret. No pointer or
wheel led that scroll, so the reveal window corrects it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant