diff --git a/packages/core/src/extensions/virtual-caret.test.ts b/packages/core/src/extensions/virtual-caret.test.ts
index a574e65..287ee98 100644
--- a/packages/core/src/extensions/virtual-caret.test.ts
+++ b/packages/core/src/extensions/virtual-caret.test.ts
@@ -1,4 +1,5 @@
-import { describe, expect, it } from 'vitest'
+import { Selection } from '@prosekit/pm/state'
+import { describe, expect, it, onTestFinished } from 'vitest'
import { page, userEvent } from 'vitest/browser'
import { setupFixture, type Fixture } from '../testing/index.ts'
@@ -211,6 +212,152 @@ 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 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
+ }
+
+ function caretWithinViewport(): boolean {
+ const rect = getCaretElement().getBoundingClientRect()
+ 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)
+ 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)
+ })
+
+ 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)
+ 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.
+ 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 scroller = mountInScroller(fixture)
+ 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(() => 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('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)
+ 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)
+ })
+})
+
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 7108fca..c12729c 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
@@ -24,6 +31,20 @@ 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
+
+// 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
@@ -116,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
@@ -132,10 +167,18 @@ class VirtualCaretView implements PluginView {
#lastRect: CaretRect | undefined
#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'))
@@ -143,24 +186,109 @@ 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)
+ 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()
}
update(view: EditorView, prevState: EditorState) {
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.#revealPending = 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.#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. 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()
+ }
+
+ 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 +299,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.#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 =
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 +335,63 @@ class VirtualCaretView implements PluginView {
forceReflow(this.#caret)
this.#caret.style.transitionProperty = ''
}
+ this.#revealIfPending()
+ }
+
+ // 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 && 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
+ 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' })
+ 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
}
}
@@ -210,11 +400,27 @@ 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. 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).
+ *
+ * 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(
new Plugin({
key,
+ state: {
+ init: () => 0,
+ apply: (tr, count) => (tr.selectionSet ? count + 1 : count),
+ },
view: (view) => new VirtualCaretView(view),
}),
)