Skip to content

fix(vim): keep the j/k cursor clear of the HUD bands when scrolling - #1154

Open
rNoz wants to merge 1 commit into
backnotprop:mainfrom
rNoz:rnoz/fix-vim-scroll-margin
Open

fix(vim): keep the j/k cursor clear of the HUD bands when scrolling#1154
rNoz wants to merge 1 commit into
backnotprop:mainfrom
rNoz:rnoz/fix-vim-scroll-margin

Conversation

@rNoz

@rNoz rNoz commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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/k motion 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 from ScrollViewportContext — 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

  • Add packages/ui/utils/vimScroll.ts:
    • computeVimScrollDelta(viewport, target, band) — a pure function returning the signed scrollTop delta needed to clear the HUD bands, or 0 when 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 to scrollIntoView({ block: 'nearest' }) when there is no such viewport, and ignores zero-size (unrendered) targets.
  • Take the scrolling element from ScrollViewportContext (useScrollViewport() in Viewer, the same node the reticle measures against) and pass it into scrollVimTargetIntoView. 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-pinning scrollIntoView; 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.
  • Route all cursor/target movement sites in packages/ui/hooks/useVimSelection.ts through scrollVimTargetIntoView (threading the context viewport), instead of scrollIntoView({ block: 'nearest' }).

Semantics and limits

  • The scroll is applied as a relative 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.
  • A target already inside the safe band produces a zero delta and does not scroll, preserving the prior "do nothing when already visible" feel.
  • The band is a fraction of viewport height clamped to [24px, 160px]; on ordinary viewports this reads as a Vim scrolloff-style margin rather than a constant recentre.
  • The move is instant, not smooth, so per-keystroke j/k tracks the caret without animation lag; the reticle's existing rAF-coalesced scroll listener repaints without a feedback loop.
  • Scope: this covers the Markdown Vim surface. The --render-html annotate 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 under DOM_TESTS=1 covering 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/ui stays browser-safe and zero-dep — no node: 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.

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
rNoz marked this pull request as ready for review July 30, 2026 06:33
@rNoz

rNoz commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

@backnotprop you are gonna like this one :) Proofs both at the issue and PR, just cooked during the last 9h of work.

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.

Vim j/k navigation parks the cursor behind the HUD bars at the top and bottom of a document

1 participant