import { canUseRegexLookbehind, union } from '@prosekit/core' import { describe, expect, it, vi } from 'vitest' import { keyboard } from 'vitest-browser-commands/playwright' import { defineTestExtension, setupTestFromExtension } from '../testing/index.ts' import { inputText } from '../testing/keyboard.ts' import { AutocompleteRule, type MatchHandler, type MatchHandlerOptions } from './autocomplete-rule.ts' import { defineAutocomplete } from './autocomplete.ts' function setupSlashMenu() { const regex = canUseRegexLookbehind() ? /(?((options) => { matching = options }) const onLeave = vi.fn(() => { if (matching) { matching = null } else { throw new Error('onLeave should not be called when there is no matching') } }) const rule = new AutocompleteRule({ regex, onEnter, onLeave }) const extension = union(defineTestExtension(), defineAutocomplete(rule)) const { editor, n, m } = setupTestFromExtension(extension) const doc = n.doc(n.paragraph('')) editor.set(doc) const isMatching = (): boolean => { return !!matching } const getMatching = (): MatchHandlerOptions => { if (!matching) { throw new Error('No matching found') } return matching } const getMatchingText = (): string => { return getMatching().match[0] } const showSelection = (): string => { const { selection, doc } = editor.state const textBackward = doc.textBetween(0, selection.from, '\n') const textSelected = doc.textBetween(selection.from, selection.to, '\n') const textForward = doc.textBetween(selection.to, doc.content.size, '\n') if (selection.empty) { return textBackward + '' + textForward } else { return textBackward + '' + textSelected + '' + textForward } } return { editor, n, m, onEnter, onLeave, getMatching, isMatching, getMatchingText, showSelection } } describe('defineAutocomplete', () => { it('can trigger onEnter', async () => { const { onEnter, onLeave } = setupSlashMenu() expect(onEnter).not.toHaveBeenCalled() expect(onLeave).not.toHaveBeenCalled() await inputText('/') expect(onEnter).toHaveBeenCalledTimes(1) await inputText('order') expect(onEnter).toHaveBeenCalledTimes(6) await inputText(' ') expect(onEnter).toHaveBeenCalledTimes(7) await inputText('list') expect(onEnter).toHaveBeenCalledTimes(11) }) it('can trigger onLeave', async () => { const { onEnter, onLeave } = setupSlashMenu() expect(onEnter).not.toHaveBeenCalled() expect(onLeave).not.toHaveBeenCalled() // Slash menu should be triggered when typing "/" await inputText('/') expect(onEnter).toHaveBeenCalledTimes(1) expect(onLeave).toHaveBeenCalledTimes(0) // Slash menu should not be triggered when typing "/ " await keyboard.press('Space') expect(onEnter).toHaveBeenCalledTimes(1) expect(onLeave).toHaveBeenCalledTimes(1) }) it('can delete the matched text', async () => { const { editor, onEnter, getMatching } = setupSlashMenu() expect(onEnter).not.toHaveBeenCalled() await inputText('/') expect(onEnter).toHaveBeenCalledTimes(1) const options = getMatching() expect(editor.state.doc.textContent).toBe('/') options.deleteMatch() expect(editor.state.doc.textContent).toBe('') }) it('can ignore the match by calling `ignoreMatch`', async () => { const { editor, onEnter, onLeave, getMatching } = setupSlashMenu() expect(onEnter).not.toHaveBeenCalled() await inputText('/') expect(onEnter).toHaveBeenCalledTimes(1) expect(onLeave).toHaveBeenCalledTimes(0) expect(editor.state.doc.textContent).toBe('/') // Typing should trigger autocomplete await inputText('a') expect(onEnter).toHaveBeenCalledTimes(2) expect(onLeave).toHaveBeenCalledTimes(0) expect(editor.state.doc.textContent).toBe('/a') // Call `ignoreMatch` to dismiss the match const options = getMatching() options.ignoreMatch() expect(onEnter).toHaveBeenCalledTimes(2) expect(onLeave).toHaveBeenCalledTimes(1) // Typing should not trigger autocomplete anymore await inputText('a') expect(onEnter).toHaveBeenCalledTimes(2) expect(onLeave).toHaveBeenCalledTimes(1) expect(editor.state.doc.textContent).toBe('/aa') }) it('can dismiss the match by deleting the matched text', async () => { const { isMatching, showSelection } = setupSlashMenu() expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) await inputText('/') expect(showSelection()).toMatchInlineSnapshot(`"/"`) expect(isMatching()).toBe(true) await keyboard.press('Backspace') expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) }) it('can recover the match after dismissing from Backspace', async () => { const { isMatching, showSelection } = setupSlashMenu() expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) await inputText('/') expect(showSelection()).toMatchInlineSnapshot(`"/"`) expect(isMatching()).toBe(true) await keyboard.press('Backspace') expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) await inputText('/') expect(showSelection()).toMatchInlineSnapshot(`"/"`) expect(isMatching()).toBe(true) }) it('can recover the match after dismissing from onLeave', async () => { const { isMatching, showSelection, getMatching } = setupSlashMenu() expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) await inputText('/') expect(showSelection()).toMatchInlineSnapshot(`"/"`) expect(isMatching()).toBe(true) const matching = getMatching() expect(matching).toBeTruthy() matching?.ignoreMatch() expect(showSelection()).toMatchInlineSnapshot(`"/"`) expect(isMatching()).toBe(false) await keyboard.press('Backspace') expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) await inputText('/') expect(showSelection()).toMatchInlineSnapshot(`"/"`) expect(isMatching()).toBe(true) }) it('can start a new match after dismissing the previous match', async () => { const { isMatching, showSelection, getMatching, getMatchingText } = setupSlashMenu() expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) await inputText('a /b') expect(showSelection()).toMatchInlineSnapshot(`"a /b"`) expect(isMatching()).toBe(true) getMatching().ignoreMatch() expect(showSelection()).toMatchInlineSnapshot(`"a /b"`) expect(isMatching()).toBe(false) await keyboard.press('Space') expect(showSelection()).toMatchInlineSnapshot(`"a /b "`) expect(isMatching()).toBe(false) await inputText('/') expect(showSelection()).toMatchInlineSnapshot(`"a /b /"`) expect(isMatching()).toBe(true) expect(getMatchingText()).toBe('/') await inputText('c') expect(showSelection()).toMatchInlineSnapshot(`"a /b /c"`) expect(isMatching()).toBe(true) expect(getMatchingText()).toBe('/c') }) it('can dismiss the match by creating a new paragraph', async () => { const { isMatching, showSelection } = setupSlashMenu() expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) await inputText('/') expect(showSelection()).toMatchInlineSnapshot(`"/"`) expect(isMatching()).toBe(true) await keyboard.press('Enter') expect(showSelection()).toMatchInlineSnapshot(` "/ " `) expect(isMatching()).toBe(false) }) it('can keep the match when selecting the text', async () => { const { isMatching, showSelection } = setupSlashMenu() expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) await inputText('/page') expect(showSelection()).toMatchInlineSnapshot(`"/page"`) expect(isMatching()).toBe(true) await keyboard.down('Shift') await keyboard.press('ArrowLeft') await keyboard.press('ArrowLeft') await keyboard.up('Shift') expect(showSelection()).toMatchInlineSnapshot(`"/page"`) expect(isMatching()).toBe(true) }) it('can ignore the match by moving the text cursor outside of the match', async () => { const { onEnter, isMatching, getMatchingText, showSelection } = setupSlashMenu() expect(onEnter).not.toHaveBeenCalled() expect(showSelection()).toMatchInlineSnapshot(`""`) expect(isMatching()).toBe(false) await inputText('a ') expect(showSelection()).toMatchInlineSnapshot(`"a "`) expect(isMatching()).toBe(false) await inputText('/') expect(showSelection()).toMatchInlineSnapshot(`"a /"`) expect(isMatching()).toBe(true) expect(getMatchingText()).toBe('/') await inputText('b') expect(showSelection()).toMatchInlineSnapshot(`"a /b"`) expect(isMatching()).toBe(true) expect(getMatchingText()).toBe('/b') await keyboard.press('ArrowLeft') expect(showSelection()).toMatchInlineSnapshot(`"a /b"`) expect(isMatching()).toBe(true) expect(getMatchingText()).toBe('/b') await keyboard.press('ArrowLeft') expect(showSelection()).toMatchInlineSnapshot(`"a /b"`) expect(isMatching()).toBe(true) expect(getMatchingText()).toBe('/b') await keyboard.press('ArrowLeft') expect(showSelection()).toMatchInlineSnapshot(`"a /b"`) expect(isMatching()).toBe(false) await keyboard.press('ArrowRight') expect(showSelection()).toMatchInlineSnapshot(`"a /b"`) expect(isMatching()).toBe(false) await keyboard.press('ArrowRight') expect(showSelection()).toMatchInlineSnapshot(`"a /b"`) expect(isMatching()).toBe(false) await keyboard.press('Backspace') await keyboard.press('Backspace') expect(showSelection()).toMatchInlineSnapshot(`"ab"`) expect(isMatching()).toBe(false) await inputText(' /') expect(showSelection()).toMatchInlineSnapshot(`"a /b"`) expect(isMatching()).toBe(true) expect(getMatchingText()).toBe('/') await inputText('c') expect(showSelection()).toMatchInlineSnapshot(`"a /cb"`) expect(isMatching()).toBe(true) expect(getMatchingText()).toBe('/c') await inputText('d') expect(showSelection()).toMatchInlineSnapshot(`"a /cdb"`) expect(isMatching()).toBe(true) expect(getMatchingText()).toBe('/cd') await keyboard.press('ArrowRight') expect(showSelection()).toMatchInlineSnapshot(`"a /cdb"`) expect(isMatching()).toBe(false) }) })