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), }), )