From 7704064fa9519545cf89a5194b749376f6fec1de Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Wed, 8 Jul 2026 17:48:26 +0100 Subject: [PATCH 1/4] fix: scroll an off-screen caret into the viewport when it is placed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core/src/extensions/virtual-caret.test.ts | 47 +++++++++ packages/core/src/extensions/virtual-caret.ts | 96 ++++++++++++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/core/src/extensions/virtual-caret.test.ts b/packages/core/src/extensions/virtual-caret.test.ts index a574e65f..866ab673 100644 --- a/packages/core/src/extensions/virtual-caret.test.ts +++ b/packages/core/src/extensions/virtual-caret.test.ts @@ -1,3 +1,4 @@ +import { Selection } from '@prosekit/pm/state' import { describe, expect, it } from 'vitest' import { page, userEvent } from 'vitest/browser' @@ -211,6 +212,52 @@ describe('virtual caret next to atom marks', () => { }) }) +describe('virtual caret viewport reveal', () => { + // Enough paragraphs to push the last one well past the viewport bottom. + function setupLongDoc(lastText: string): Fixture { + const fixture = setupFixture({ extensionOptions: { markMode: 'hide' } }) + const { n } = fixture + const fillers = Array.from({ length: 80 }, (_, index) => n.paragraph(`filler ${index}`)) + fixture.set(n.doc(n.paragraph('first'), ...fillers, n.paragraph(lastText))) + return fixture + } + + function caretWithinViewport(): boolean { + const rect = getCaretElement().getBoundingClientRect() + return rect.height > 0 && rect.top >= 0 && rect.bottom <= window.innerHeight + } + + it('scrolls an off-screen caret into view when the editor gains focus', async () => { + using fixture = setupLongDoc('last') + window.scrollTo(0, 0) + fixture.view.focus() + await expect.element(caret).toBeVisible() + await expect.poll(caretWithinViewport).toBe(true) + expect(window.scrollY).toBeGreaterThan(0) + }) + + it('scrolls to the caret after a programmatic selection jump', async () => { + using fixture = setupLongDoc('last') + const { view } = fixture + view.dispatch(view.state.tr.setSelection(Selection.atStart(view.state.doc))) + view.focus() + await expect.element(caret).toBeVisible() + window.scrollTo(0, 0) + view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc))) + await expect.poll(caretWithinViewport).toBe(true) + expect(window.scrollY).toBeGreaterThan(0) + }) + + it('leaves the scroll position alone while the editor is blurred', async () => { + using fixture = setupLongDoc('last') + const { view } = fixture + window.scrollTo(0, 0) + view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc))) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(window.scrollY).toBe(0) + }) +}) + describe('virtual caret tails (hide mode)', () => { it('shows a right tail after a closing run', async () => { using fixture = setupMode('hide', 'foo **bold** bar') diff --git a/packages/core/src/extensions/virtual-caret.ts b/packages/core/src/extensions/virtual-caret.ts index 7108fcaf..4592094b 100644 --- a/packages/core/src/extensions/virtual-caret.ts +++ b/packages/core/src/extensions/virtual-caret.ts @@ -24,6 +24,10 @@ const BLINK_ANIMATIONS = ['md-virtual-caret-blink', 'md-virtual-caret-blink2'] a // line-height; stand the caret taller around its center. const CARET_STRETCH = 1.2 +// Breathing room kept between a revealed caret and the viewport edge, so the +// caret does not sit flush against the boundary after a scroll. +const SCROLL_MARGIN = 16 + interface CaretRect { left: number top: number @@ -132,6 +136,8 @@ class VirtualCaretView implements PluginView { #lastRect: CaretRect | undefined #lastTail: CaretTail | undefined #blinkIndex = 0 + #scrollPending = false + #pointerActive = false constructor(view: EditorView) { this.#view = view @@ -143,6 +149,10 @@ class VirtualCaretView implements PluginView { this.#caret.dataset.testid = 'virtual-caret' view.dom.insertAdjacentElement('afterend', this.#layer) this.#document.addEventListener('selectionchange', this.#reposition) + view.dom.addEventListener('focusin', this.#handleFocusIn) + view.dom.addEventListener('pointerdown', this.#handlePointerDown) + this.#document.addEventListener('pointerup', this.#handlePointerRelease) + this.#document.addEventListener('pointercancel', this.#handlePointerRelease) if (typeof ResizeObserver !== 'undefined') { this.#resizeObserver = new ResizeObserver(this.#reposition) this.#resizeObserver.observe(view.dom) @@ -151,16 +161,48 @@ class VirtualCaretView implements PluginView { } update(view: EditorView, prevState: EditorState) { - if (!view.state.selection.eq(prevState.selection)) this.#restartBlink() + if (!view.state.selection.eq(prevState.selection)) { + this.#restartBlink() + // A pointer places the caret where the user can already see it, and + // scrolling between pointerdown and the click's selection dispatch + // would displace the click. + if (view.hasFocus() && !this.#pointerActive) this.#scrollPending = true + } this.#reposition() } destroy() { this.#document.removeEventListener('selectionchange', this.#reposition) + this.#view.dom.removeEventListener('focusin', this.#handleFocusIn) + this.#view.dom.removeEventListener('pointerdown', this.#handlePointerDown) + this.#document.removeEventListener('pointerup', this.#handlePointerRelease) + this.#document.removeEventListener('pointercancel', this.#handlePointerRelease) this.#resizeObserver?.disconnect() this.#layer.remove() } + // Focus is what makes the caret appear (CSS displays it only under + // `.ProseMirror-focused`), and `EditorView.focus` restores the selection + // without scrolling, so a programmatic focus deep in a long document would + // otherwise leave the caret off-screen. + readonly #handleFocusIn = (): void => { + if (!this.#pointerActive) this.#scrollPending = true + this.#reposition() + } + + readonly #handlePointerDown = (): void => { + this.#pointerActive = true + } + + // ProseMirror dispatches a click's selection from its own document-level + // mouseup listener, which can run after this one; clearing in a microtask + // keeps the flag raised through that dispatch. + readonly #handlePointerRelease = (): void => { + queueMicrotask(() => { + this.#pointerActive = false + }) + } + #restartBlink() { this.#blinkIndex = 1 - this.#blinkIndex this.#caret.style.animationName = BLINK_ANIMATIONS[this.#blinkIndex] @@ -171,14 +213,19 @@ class VirtualCaretView implements PluginView { if (view.isDestroyed) return const state = view.state const selection = state.selection - const rect = isTextSelection(selection) && selection.empty ? measureCaretRect(view) : undefined + const showsCaret = isTextSelection(selection) && selection.empty + const rect = showsCaret ? measureCaretRect(view) : undefined + if (!showsCaret) this.#scrollPending = false // In hide mode the two doc positions at a hidden run boundary render at // one x; the tail (typing affinity) tells them apart. const tail = rect != null && getMarkMode(state) === 'hide' ? getCaretTail(state, selection.head) : undefined - if (sameRect(rect, this.#lastRect) && tail === this.#lastTail) return + if (sameRect(rect, this.#lastRect) && tail === this.#lastTail) { + this.#revealIfPending() + return + } const wasHidden = this.#lastRect == null this.#lastRect = rect this.#lastTail = tail @@ -202,6 +249,44 @@ class VirtualCaretView implements PluginView { forceReflow(this.#caret) this.#caret.style.transitionProperty = '' } + this.#revealIfPending() + } + + // Consumes a pending reveal by scrolling the caret's destination into view. + // The target is measured from the editor state, never the native selection: + // right after a focus the browser has parked the DOM selection at the start + // of the contenteditable and ProseMirror restores it a beat later, so the + // native rect can point at the wrong place. An unmeasurable target (host + // not laid out yet) keeps the flag raised; the ResizeObserver retries once + // layout settles. + #revealIfPending(): void { + if (!this.#scrollPending) return + const view = this.#view + if (!view.hasFocus()) return + const rect = findCoordsCaretRect(view) ?? findAtomCaretRect(view) + if (rect == null) return + this.#scrollPending = false + this.#scrollRectIntoView(rect) + } + + // Scrolls the minimum distance to bring the caret's destination into view, + // via a phantom sibling at that spot: the caret element itself glides there + // (left/top transition), so its own box may still be mid-flight, and + // `scrollIntoView` on a phantom walks nested scrollers for free. With + // `nearest` the call is a no-op when the caret is already visible. + #scrollRectIntoView(rect: CaretRect): void { + const layerRect = this.#layer.getBoundingClientRect() + const phantom = this.#document.createElement('div') + const style = phantom.style + style.position = 'absolute' + style.left = `${rect.left - layerRect.left}px` + style.top = `${rect.top - layerRect.top}px` + style.width = '2px' + style.height = `${rect.height}px` + style.setProperty('scroll-margin', `${SCROLL_MARGIN}px`) + this.#layer.appendChild(phantom) + phantom.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + phantom.remove() } } @@ -210,6 +295,11 @@ class VirtualCaretView implements PluginView { * (`caret-color: transparent`). The native DOM selection stays fully alive, * so IME, clicks, and typing keep their native behavior; only the caret pixels * are ours. Applies to every mark mode. + * + * A caret placed off-screen — focus restored at the end of a long document, + * or a keyboard/programmatic selection move — is revealed by scrolling the + * nearest scroller the minimum distance. Pointer-driven placement never + * scrolls: a click already happens inside the viewport. */ export function defineVirtualCaret(): PlainExtension { return definePlugin( From 9026e3f0a15463dfac55eef38803fbad41de208f Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Wed, 8 Jul 2026 18:00:00 +0100 Subject: [PATCH 2/4] refactor: reveal the caret only on explicit selection placement 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 --- .../core/src/extensions/virtual-caret.test.ts | 52 +++++++++++++++++-- packages/core/src/extensions/virtual-caret.ts | 39 ++++++++++---- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/packages/core/src/extensions/virtual-caret.test.ts b/packages/core/src/extensions/virtual-caret.test.ts index 866ab673..3b231636 100644 --- a/packages/core/src/extensions/virtual-caret.test.ts +++ b/packages/core/src/extensions/virtual-caret.test.ts @@ -1,5 +1,5 @@ import { Selection } from '@prosekit/pm/state' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { page, userEvent } from 'vitest/browser' import { setupFixture, type Fixture } from '../testing/index.ts' @@ -214,11 +214,15 @@ describe('virtual caret next to atom marks', () => { describe('virtual caret viewport reveal', () => { // Enough paragraphs to push the last one well past the viewport bottom. - function setupLongDoc(lastText: string): Fixture { - const fixture = setupFixture({ extensionOptions: { markMode: 'hide' } }) + function fillLongDoc(fixture: Fixture, lastText: string): void { const { n } = fixture const fillers = Array.from({ length: 80 }, (_, index) => n.paragraph(`filler ${index}`)) fixture.set(n.doc(n.paragraph('first'), ...fillers, n.paragraph(lastText))) + } + + function setupLongDoc(lastText: string): Fixture { + const fixture = setupFixture({ extensionOptions: { markMode: 'hide' } }) + fillLongDoc(fixture, lastText) return fixture } @@ -256,6 +260,48 @@ describe('virtual caret viewport reveal', () => { await new Promise((resolve) => setTimeout(resolve, 50)) expect(window.scrollY).toBe(0) }) + + it('does not scroll when a doc change merely remaps the selection', async () => { + using fixture = setupLongDoc('last') + const { view } = fixture + view.focus() + await expect.poll(caretWithinViewport).toBe(true) + window.scrollTo(0, 0) + // Insert above the caret without touching the selection: the caret's + // position shifts by mapping, which must not count as a placement. + view.dispatch(view.state.tr.insertText('x', 1)) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(window.scrollY).toBe(0) + }) + + it('scrolls a nested scrollable container to reveal the caret', async () => { + using fixture = setupFixture({ mount: false, extensionOptions: { markMode: 'hide' } }) + const { editor } = fixture + const scroller = document.createElement('div') + scroller.style.height = '300px' + scroller.style.overflowY = 'auto' + document.body.prepend(scroller) + editor.mount(scroller.appendChild(document.createElement('div'))) + onTestFinished(() => { + editor.unmount() + scroller.remove() + }) + fillLongDoc(fixture, 'last') + window.scrollTo(0, 0) + scroller.scrollTop = 0 + fixture.view.focus() + await expect.element(caret).toBeVisible() + await expect.poll(() => scroller.scrollTop).toBeGreaterThan(0) + // The caret element itself catches up once ProseMirror restores the DOM + // selection, a beat after focus. + await expect + .poll(() => { + const caretRect = getCaretElement().getBoundingClientRect() + const scrollerRect = scroller.getBoundingClientRect() + return caretRect.top >= scrollerRect.top && caretRect.bottom <= scrollerRect.bottom + }) + .toBe(true) + }) }) describe('virtual caret tails (hide mode)', () => { diff --git a/packages/core/src/extensions/virtual-caret.ts b/packages/core/src/extensions/virtual-caret.ts index 4592094b..2ee35361 100644 --- a/packages/core/src/extensions/virtual-caret.ts +++ b/packages/core/src/extensions/virtual-caret.ts @@ -16,7 +16,14 @@ import { } from './hidden-run.ts' import { getMarkMode } from './mark-mode.ts' -const key = new PluginKey('meowdown-virtual-caret') +// Plugin state counts transactions that explicitly set the selection, so the +// view can tell a deliberate caret placement from a selection that merely got +// remapped by a doc change elsewhere — only the former may scroll, matching +// ProseMirror's opt-in `scrollIntoView` convention. A counter rather than the +// last transaction's `selectionSet` flag: other plugins append transactions +// after a selection-setting one within a single dispatch, which would +// otherwise mask it. +const key = new PluginKey('meowdown-virtual-caret') const BLINK_ANIMATIONS = ['md-virtual-caret-blink', 'md-virtual-caret-blink2'] as const @@ -136,11 +143,13 @@ class VirtualCaretView implements PluginView { #lastRect: CaretRect | undefined #lastTail: CaretTail | undefined #blinkIndex = 0 - #scrollPending = false + #revealPending = false #pointerActive = false + #selectionSetsSeen: number constructor(view: EditorView) { this.#view = view + this.#selectionSetsSeen = key.getState(view.state) ?? 0 this.#document = view.dom.ownerDocument this.#layer = this.#document.createElement('div') this.#layer.className = 'md-virtual-caret-layer' @@ -161,12 +170,14 @@ class VirtualCaretView implements PluginView { } update(view: EditorView, prevState: EditorState) { - if (!view.state.selection.eq(prevState.selection)) { - this.#restartBlink() + if (!view.state.selection.eq(prevState.selection)) this.#restartBlink() + const selectionSets = key.getState(view.state) ?? 0 + if (selectionSets !== this.#selectionSetsSeen) { + this.#selectionSetsSeen = selectionSets // A pointer places the caret where the user can already see it, and // scrolling between pointerdown and the click's selection dispatch // would displace the click. - if (view.hasFocus() && !this.#pointerActive) this.#scrollPending = true + if (view.hasFocus() && !this.#pointerActive) this.#revealPending = true } this.#reposition() } @@ -186,7 +197,7 @@ class VirtualCaretView implements PluginView { // without scrolling, so a programmatic focus deep in a long document would // otherwise leave the caret off-screen. readonly #handleFocusIn = (): void => { - if (!this.#pointerActive) this.#scrollPending = true + if (!this.#pointerActive) this.#revealPending = true this.#reposition() } @@ -215,7 +226,7 @@ class VirtualCaretView implements PluginView { const selection = state.selection const showsCaret = isTextSelection(selection) && selection.empty const rect = showsCaret ? measureCaretRect(view) : undefined - if (!showsCaret) this.#scrollPending = false + if (!showsCaret) this.#revealPending = false // In hide mode the two doc positions at a hidden run boundary render at // one x; the tail (typing affinity) tells them apart. const tail = @@ -260,12 +271,12 @@ class VirtualCaretView implements PluginView { // not laid out yet) keeps the flag raised; the ResizeObserver retries once // layout settles. #revealIfPending(): void { - if (!this.#scrollPending) return + if (!this.#revealPending) return const view = this.#view if (!view.hasFocus()) return const rect = findCoordsCaretRect(view) ?? findAtomCaretRect(view) if (rect == null) return - this.#scrollPending = false + this.#revealPending = false this.#scrollRectIntoView(rect) } @@ -298,13 +309,19 @@ class VirtualCaretView implements PluginView { * * A caret placed off-screen — focus restored at the end of a long document, * or a keyboard/programmatic selection move — is revealed by scrolling the - * nearest scroller the minimum distance. Pointer-driven placement never - * scrolls: a click already happens inside the viewport. + * nearest scroller the minimum distance. Only explicit placements count: a + * doc change that merely remaps the selection never scrolls, and neither + * does pointer-driven placement (a click already happens inside the + * viewport). */ export function defineVirtualCaret(): PlainExtension { return definePlugin( new Plugin({ key, + state: { + init: () => 0, + apply: (tr, count) => (tr.selectionSet ? count + 1 : count), + }, view: (view) => new VirtualCaretView(view), }), ) From a1673976006d7565c25770908a3e69b814606082 Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Wed, 8 Jul 2026 18:10:59 +0100 Subject: [PATCH 3/4] fix: keep the caret revealed through the iOS keyboard raise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core/src/extensions/virtual-caret.test.ts | 73 ++++++++--- packages/core/src/extensions/virtual-caret.ts | 115 ++++++++++++++++-- 2 files changed, 163 insertions(+), 25 deletions(-) diff --git a/packages/core/src/extensions/virtual-caret.test.ts b/packages/core/src/extensions/virtual-caret.test.ts index 3b231636..dcd1a188 100644 --- a/packages/core/src/extensions/virtual-caret.test.ts +++ b/packages/core/src/extensions/virtual-caret.test.ts @@ -231,6 +231,32 @@ describe('virtual caret viewport reveal', () => { return rect.height > 0 && rect.top >= 0 && rect.bottom <= window.innerHeight } + function caretWithin(scroller: Element): boolean { + const rect = getCaretElement().getBoundingClientRect() + const box = scroller.getBoundingClientRect() + return rect.height > 0 && rect.top >= box.top && rect.bottom <= box.bottom + } + + // A user takeover leads with a pointerdown, which closes the reveal window. + function userTakeover(): void { + document.dispatchEvent(new PointerEvent('pointerdown')) + } + + // Mounts the fixture's editor inside a fixed-height scrollable container, + // the shape of a keyboard-aware mobile shell. + function mountInScroller(fixture: Fixture): HTMLElement { + const scroller = document.createElement('div') + scroller.style.height = '300px' + scroller.style.overflowY = 'auto' + document.body.prepend(scroller) + fixture.editor.mount(scroller.appendChild(document.createElement('div'))) + onTestFinished(() => { + fixture.editor.unmount() + scroller.remove() + }) + return scroller + } + it('scrolls an off-screen caret into view when the editor gains focus', async () => { using fixture = setupLongDoc('last') window.scrollTo(0, 0) @@ -266,6 +292,7 @@ describe('virtual caret viewport reveal', () => { const { view } = fixture view.focus() await expect.poll(caretWithinViewport).toBe(true) + userTakeover() window.scrollTo(0, 0) // Insert above the caret without touching the selection: the caret's // position shifts by mapping, which must not count as a placement. @@ -276,16 +303,7 @@ describe('virtual caret viewport reveal', () => { it('scrolls a nested scrollable container to reveal the caret', async () => { using fixture = setupFixture({ mount: false, extensionOptions: { markMode: 'hide' } }) - const { editor } = fixture - const scroller = document.createElement('div') - scroller.style.height = '300px' - scroller.style.overflowY = 'auto' - document.body.prepend(scroller) - editor.mount(scroller.appendChild(document.createElement('div'))) - onTestFinished(() => { - editor.unmount() - scroller.remove() - }) + const scroller = mountInScroller(fixture) fillLongDoc(fixture, 'last') window.scrollTo(0, 0) scroller.scrollTop = 0 @@ -294,13 +312,34 @@ describe('virtual caret viewport reveal', () => { await expect.poll(() => scroller.scrollTop).toBeGreaterThan(0) // The caret element itself catches up once ProseMirror restores the DOM // selection, a beat after focus. - await expect - .poll(() => { - const caretRect = getCaretElement().getBoundingClientRect() - const scrollerRect = scroller.getBoundingClientRect() - return caretRect.top >= scrollerRect.top && caretRect.bottom <= scrollerRect.bottom - }) - .toBe(true) + await expect.poll(() => caretWithin(scroller)).toBe(true) + }) + + it('re-reveals the caret when the scroller shrinks after focus (keyboard raise)', async () => { + using fixture = setupFixture({ mount: false, extensionOptions: { markMode: 'hide' } }) + const scroller = mountInScroller(fixture) + fillLongDoc(fixture, 'last') + scroller.scrollTop = 0 + fixture.view.focus() + await expect.poll(() => caretWithin(scroller)).toBe(true) + // The software keyboard raises after focus and the host shell shrinks + // the scroller by its height, pushing the revealed caret below the fold. + scroller.style.height = '150px' + await expect.poll(() => caretWithin(scroller)).toBe(true) + }) + + it('stops re-revealing once the user takes over', async () => { + using fixture = setupFixture({ mount: false, extensionOptions: { markMode: 'hide' } }) + const scroller = mountInScroller(fixture) + fillLongDoc(fixture, 'last') + scroller.scrollTop = 0 + fixture.view.focus() + await expect.poll(() => caretWithin(scroller)).toBe(true) + userTakeover() + const settled = scroller.scrollTop + scroller.style.height = '150px' + await new Promise((resolve) => setTimeout(resolve, 120)) + expect(scroller.scrollTop).toBe(settled) }) }) diff --git a/packages/core/src/extensions/virtual-caret.ts b/packages/core/src/extensions/virtual-caret.ts index 2ee35361..c12729c2 100644 --- a/packages/core/src/extensions/virtual-caret.ts +++ b/packages/core/src/extensions/virtual-caret.ts @@ -35,6 +35,16 @@ const CARET_STRETCH = 1.2 // caret does not sit flush against the boundary after a scroll. const SCROLL_MARGIN = 16 +// How long a focus keeps re-revealing the caret through layout churn. The +// reveal at focus time runs against the pre-keyboard layout: the iOS +// software keyboard reports its height only once it animates up, and a +// keyboard-aware host shell then shrinks the editor's scroller by that +// height — dropping an end-of-note caret below the fold with nothing left +// to re-scroll it. Long notes also keep growing after focus as images size +// in. Each disturbance re-arms the window; a pointerdown or wheel anywhere +// ends it, because the user has taken over. +const REVEAL_WINDOW_MS = 1500 + interface CaretRect { left: number top: number @@ -127,6 +137,20 @@ function sameRect(left: CaretRect | undefined, right: CaretRect | undefined): bo return left.left === right.left && left.top === right.top && left.height === right.height } +// The nearest ancestor that can scroll vertically, or the page scroller. +function closestScrollable(element: Element): Element | null { + for (let node = element.parentElement; node != null; node = node.parentElement) { + const { overflowY } = getComputedStyle(node) + if ( + (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') && + node.scrollHeight > node.clientHeight + ) { + return node + } + } + return element.ownerDocument.scrollingElement +} + // The caret lives in a zero-height in-flow layer right after `view.dom`, never // inside the contenteditable: a `contenteditable=false` element inside the // content DOM shifts the browser's insertion point at the document edges @@ -144,13 +168,17 @@ class VirtualCaretView implements PluginView { #lastTail: CaretTail | undefined #blinkIndex = 0 #revealPending = false + #revealUntil = 0 #pointerActive = false #selectionSetsSeen: number + readonly #revealWindowObserver: ResizeObserver | undefined + readonly #visualViewport: VisualViewport | null constructor(view: EditorView) { this.#view = view this.#selectionSetsSeen = key.getState(view.state) ?? 0 this.#document = view.dom.ownerDocument + this.#visualViewport = this.#document.defaultView?.visualViewport ?? null this.#layer = this.#document.createElement('div') this.#layer.className = 'md-virtual-caret-layer' this.#caret = this.#layer.appendChild(this.#document.createElement('div')) @@ -162,9 +190,17 @@ class VirtualCaretView implements PluginView { view.dom.addEventListener('pointerdown', this.#handlePointerDown) this.#document.addEventListener('pointerup', this.#handlePointerRelease) this.#document.addEventListener('pointercancel', this.#handlePointerRelease) + this.#document.addEventListener('pointerdown', this.#cancelRevealWindow) + this.#document.addEventListener('wheel', this.#cancelRevealWindow, { passive: true }) + this.#document.addEventListener('scroll', this.#handleLayoutShift, { + capture: true, + passive: true, + }) + this.#visualViewport?.addEventListener('resize', this.#handleLayoutShift) if (typeof ResizeObserver !== 'undefined') { this.#resizeObserver = new ResizeObserver(this.#reposition) this.#resizeObserver.observe(view.dom) + this.#revealWindowObserver = new ResizeObserver(this.#handleLayoutShift) } this.#reposition() } @@ -188,16 +224,55 @@ class VirtualCaretView implements PluginView { this.#view.dom.removeEventListener('pointerdown', this.#handlePointerDown) this.#document.removeEventListener('pointerup', this.#handlePointerRelease) this.#document.removeEventListener('pointercancel', this.#handlePointerRelease) + this.#document.removeEventListener('pointerdown', this.#cancelRevealWindow) + this.#document.removeEventListener('wheel', this.#cancelRevealWindow) + this.#document.removeEventListener('scroll', this.#handleLayoutShift, { capture: true }) + this.#visualViewport?.removeEventListener('resize', this.#handleLayoutShift) this.#resizeObserver?.disconnect() + this.#revealWindowObserver?.disconnect() this.#layer.remove() } // Focus is what makes the caret appear (CSS displays it only under // `.ProseMirror-focused`), and `EditorView.focus` restores the selection // without scrolling, so a programmatic focus deep in a long document would - // otherwise leave the caret off-screen. + // otherwise leave the caret off-screen. The reveal window arms even for a + // pointer-driven focus: the tap's own placement is visible, but the + // keyboard it raises can still push the caret off afterwards. readonly #handleFocusIn = (): void => { if (!this.#pointerActive) this.#revealPending = true + this.#armRevealWindow() + this.#reposition() + } + + #armRevealWindow(): void { + this.#revealUntil = performance.now() + REVEAL_WINDOW_MS + const observer = this.#revealWindowObserver + if (observer == null) return + observer.disconnect() + // Watch every ancestor: the keyboard-raise shrink lands on whichever + // element the host sizes against the keyboard, and a shrinking scroller + // fires no scroll event of its own. + for (let node = this.#layer.parentElement; node != null; node = node.parentElement) { + observer.observe(node) + } + } + + readonly #cancelRevealWindow = (): void => { + this.#revealUntil = 0 + this.#revealPending = false + this.#revealWindowObserver?.disconnect() + } + + // A layout disturbance while the reveal window is open — the keyboard + // raise shrinking an ancestor, late content growth, the visual viewport + // resizing, a stray non-user scroll (iOS WebKit nudging toward the newly + // focused element) — re-reveals the caret and re-arms the window. User + // scrolls always lead with a pointerdown or a wheel event, which close + // the window before their scroll arrives here. + readonly #handleLayoutShift = (): void => { + if (performance.now() > this.#revealUntil) return + this.#revealUntil = performance.now() + REVEAL_WINDOW_MS this.#reposition() } @@ -263,17 +338,20 @@ class VirtualCaretView implements PluginView { this.#revealIfPending() } - // Consumes a pending reveal by scrolling the caret's destination into view. - // The target is measured from the editor state, never the native selection: - // right after a focus the browser has parked the DOM selection at the start - // of the contenteditable and ProseMirror restores it a beat later, so the - // native rect can point at the wrong place. An unmeasurable target (host - // not laid out yet) keeps the flag raised; the ResizeObserver retries once + // Consumes a pending reveal — or re-reveals while the reveal window is + // open — by scrolling the caret's destination into view. The target is + // measured from the editor state, never the native selection: right after + // a focus the browser has parked the DOM selection at the start of the + // contenteditable and ProseMirror restores it a beat later, so the native + // rect can point at the wrong place. An unmeasurable target (host not + // laid out yet) keeps the flag raised; the ResizeObserver retries once // layout settles. #revealIfPending(): void { - if (!this.#revealPending) return + if (!this.#revealPending && performance.now() > this.#revealUntil) return const view = this.#view if (!view.hasFocus()) return + const selection = view.state.selection + if (!isTextSelection(selection) || !selection.empty) return const rect = findCoordsCaretRect(view) ?? findAtomCaretRect(view) if (rect == null) return this.#revealPending = false @@ -297,8 +375,24 @@ class VirtualCaretView implements PluginView { style.setProperty('scroll-margin', `${SCROLL_MARGIN}px`) this.#layer.appendChild(phantom) phantom.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + this.#nudgeAboveKeyboard(phantom) phantom.remove() } + + // `scrollIntoView` clamps to the layout viewport, but a software keyboard + // can overlay it, leaving only the shorter visual viewport actually + // visible. (Hosts whose layout tracks the keyboard never hit this — their + // scrollers already end at the keyboard's top.) Nudge the nearest + // scroller the rest of the way so the caret clears the keyboard. + #nudgeAboveKeyboard(phantom: HTMLElement): void { + const viewport = this.#visualViewport + if (viewport == null) return + const visibleBottom = viewport.offsetTop + viewport.height - SCROLL_MARGIN + const overflow = phantom.getBoundingClientRect().bottom - visibleBottom + if (overflow <= 0) return + const scroller = closestScrollable(phantom) + if (scroller != null) scroller.scrollTop += overflow + } } /** @@ -313,6 +407,11 @@ class VirtualCaretView implements PluginView { * doc change that merely remaps the selection never scrolls, and neither * does pointer-driven placement (a click already happens inside the * viewport). + * + * A focus also opens a reveal window ({@link REVEAL_WINDOW_MS}) that keeps + * the caret visible through the layout churn that follows — the software + * keyboard raising and shrinking the host shell, images sizing in — until + * the layout goes quiet or the user takes over with a pointer or wheel. */ export function defineVirtualCaret(): PlainExtension { return definePlugin( From 73fc388dfb5bcefdce2c083dd02759f77a7fd54b Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Wed, 8 Jul 2026 18:17:28 +0100 Subject: [PATCH 4/4] test: cover a stray host scroll yanking the view off a fresh caret 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 --- .../core/src/extensions/virtual-caret.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/core/src/extensions/virtual-caret.test.ts b/packages/core/src/extensions/virtual-caret.test.ts index dcd1a188..287ee98b 100644 --- a/packages/core/src/extensions/virtual-caret.test.ts +++ b/packages/core/src/extensions/virtual-caret.test.ts @@ -328,6 +328,21 @@ describe('virtual caret viewport reveal', () => { await expect.poll(() => caretWithin(scroller)).toBe(true) }) + it('re-reveals after a stray programmatic scroll right after focus', async () => { + using fixture = setupFixture({ mount: false, extensionOptions: { markMode: 'hide' } }) + const scroller = mountInScroller(fixture) + fillLongDoc(fixture, 'last') + scroller.scrollTop = 0 + fixture.view.focus() + await expect.poll(() => caretWithin(scroller)).toBe(true) + // A host scroll writer (a scroll restore, a jump-to-top) yanks the view + // away from the just-placed caret. No pointer or wheel led the scroll, + // so it is not the user's and the reveal window corrects it. + scroller.scrollTop = 0 + await expect.poll(() => caretWithin(scroller)).toBe(true) + expect(scroller.scrollTop).toBeGreaterThan(0) + }) + it('stops re-revealing once the user takes over', async () => { using fixture = setupFixture({ mount: false, extensionOptions: { markMode: 'hide' } }) const scroller = mountInScroller(fixture)