fix: scroll an off-screen caret into the viewport when it is placed#265
Open
maccman wants to merge 4 commits into
Open
fix: scroll an off-screen caret into the viewport when it is placed#265maccman wants to merge 4 commits into
maccman wants to merge 4 commits into
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
commit: |
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The virtual caret only displays while the editor is focused, but
EditorView.focus()restores the selection withpreventScroll, and programmaticsetSelectiondispatches 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.
VirtualCaretViewraises 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 usingscrollIntoView({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.domobserver, 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),
scrollIntoViewclamps 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
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-inscrollIntoViewconvention. A counter rather than the last transaction's flag, since other plugins append transactions after a selection-setting one within a single dispatch.coordsAtPos), never the native selection, so the reveal can't lock onto the wrong spot.useCaretRevealand 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