Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 148 additions & 1 deletion packages/core/src/extensions/virtual-caret.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<a>')
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<a>')
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<a>')
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<a>')
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<a>')
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<a>')
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**<a> bar')
Expand Down
Loading
Loading