fix(vim): keep the j/k cursor clear of the HUD bands when scrolling - #1154
Open
rNoz wants to merge 1 commit into
Open
fix(vim): keep the j/k cursor clear of the HUD bands when scrolling#1154rNoz wants to merge 1 commit into
rNoz wants to merge 1 commit into
Conversation
Document Vim navigation moved the cursor with
`Element.scrollIntoView({ block: 'nearest' })`, which parks the target
flush against the nearest viewport edge — exactly where the sticky action
bar (top) and the key HUD / status pill (bottom) float. Motion to the top
or bottom of a document then hid the caret behind an overlay, while a mouse
wheel (which the browser lets overshoot) kept the same line nearer centre.
Add vimScroll.ts: a pure computeVimScrollDelta returning the signed
scrollTop delta needed to clear a HUD band at each edge (0 when the target
is already inside the safe band; a target taller than the band aligns to
its top edge so reading order wins), resolveVimScrollMargin sizing the band
as clamp(20% of viewport height, 24px..160px), and a scrollVimTargetIntoView
wrapper.
The scrolling element is the native-scroll host fed through
ScrollViewportContext — it carries no [data-overlayscrollbars-viewport]
attribute — so the wrapper takes that element from the caller
(useScrollViewport() in Viewer, the same node the reticle measures against)
rather than rediscovering it by selector. It still tries the
OverlayScrollbars attribute for real-library hosts, then the historical
scrollIntoView fallback, so behaviour never regresses.
Route every cursor/target move in useVimSelection through it.
rNoz
marked this pull request as ready for review
July 30, 2026 06:33
Contributor
Author
|
@backnotprop you are gonna like this one :) Proofs both at the issue and PR, just cooked during the last 9h of work. |
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.
Summary
Fixes #1153.
Document Vim navigation now reveals the cursor target with a margin instead of pinning it flush against the viewport edge, so
j/kmotion no longer parks the caret behind the sticky action bar (top) or the key HUD / status pill (bottom). A target already inside the safe band does not scroll, and a target with no scroll viewport falls back to the previous behavior unchanged.The scroll math lives in a small pure helper (
computeVimScrollDelta); the piece that makes it actually engage is where the viewport comes from. The document scroll host is a native-scroll<div>with no OverlayScrollbars attribute to rediscover, so the helper takes the scrolling element fromScrollViewportContext— the same node the reticle already measures against — rather than by selector. Resolving it by the (absent)[data-overlayscrollbars-viewport]attribute would silently match nothing and fall back to the old edge-pinning behavior, which is exactly the trap this avoids.Proofs using Plannotator in my local integration branch, including this feature:
Screen.Recording.2026-07-30.at.08.12.03.mov
As you see, working flawlessly and w/o requiring the mouse to scroll and view items under the HUD overlays.
Why
Every cursor/target move used
Element.scrollIntoView({ block: 'nearest' }), which pins the target flush against the nearest viewport edge — exactly where the fixed HUD overlays float. Keyboard motion to the top or bottom of a document then hid the caret behind an overlay, while a mouse wheel (which the browser lets overshoot) kept the same line nearer the centre. This reproduces the mouse feel for keyboard motion.Changes
packages/ui/utils/vimScroll.ts:computeVimScrollDelta(viewport, target, band)— a pure function returning the signedscrollTopdelta needed to clear the HUD bands, or0when the target already sits inside the safe band. A target taller than the band is aligned to its top edge (reading order wins — the start of the block is never pushed above the top margin to chase its bottom).resolveVimScrollMargin(viewportHeight)— the band size,clamp(20% of height, 24px..160px), so short viewports keep a usable margin and tall ones do not reserve most of the screen.scrollVimTargetIntoView(element)— resolves the owning scroll viewport the same way the reticle geometry does ([data-overlayscrollbars-viewport]), widens the top band to also clear a sticky action bar when present, and applies the delta. Falls back toscrollIntoView({ block: 'nearest' })when there is no such viewport, and ignores zero-size (unrendered) targets.ScrollViewportContext(useScrollViewport()inViewer, the same node the reticle measures against) and pass it intoscrollVimTargetIntoView. The document scroll host is a native-scroll<div>(OverlayScrollArea) that carries no[data-overlayscrollbars-viewport]attribute, so resolving the viewport by that selector silently matched nothing and fell back to the old edge-pinningscrollIntoView; taking the element from context is what makes the band actually engage. The selector is kept as a secondary fallback for hosts that mount the real OverlayScrollbars library.packages/ui/hooks/useVimSelection.tsthroughscrollVimTargetIntoView(threading the context viewport), instead ofscrollIntoView({ block: 'nearest' }).Semantics and limits
viewport.scrollTop += delta; the browser clamps to[0, scrollHeight - clientHeight], so a delta larger than the available room simply reveals as far as the document allows.[24px, 160px]; on ordinary viewports this reads as a Vimscrolloff-style margin rather than a constant recentre.j/ktracks the caret without animation lag; the reticle's existing rAF-coalesced scroll listener repaints without a feedback loop.--render-htmlannotate surface has its own injected-script navigation with the same edge-parking behavior; it is not addressed here.Validation
packages/ui/utils/vimScroll.test.ts: 16 passed (7 pure-math cases covering the safe band, the j-to-bottom and k-to-top bugs, tall-target top-alignment, a too-tall target straddling both edges, and a viewport offset from the page top; 3 margin clamp cases; 6 DOM cases underDOM_TESTS=1covering the explicit-viewport scroll, the attribute-fallback path, an already-safe no-op, sticky-bar widening, the no-viewport fallback, and a zero-size target).npx tsc --noEmit -p packages/ui/tsconfig.json: clean.npx tsc --noEmit -p packages/ui/tsconfig.strict-consumer.json: clean (@plannotator/core/uistays browser-safe and zero-dep — nonode:imports).Compatibility
The helper is additive. Any target without a resolvable OverlayScrollbars viewport takes the identical
scrollIntoView({ block: 'nearest' })path as before, so non-Plannotator hosts and any surface without the reticle are unchanged. No public API, prop, or payload changes.Related work
None. This is a self-contained ergonomics fix to Vim-mode document navigation.