2026-04-23 22:06:01 +02:00
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
|
|
|
|
|
|
|
|
|
|
function createFixture() {
|
|
|
|
|
let beforeInputListener: ((event: InputEvent) => void) | null = null
|
|
|
|
|
const transaction = { insertText: vi.fn(() => transaction) }
|
2026-04-29 17:12:29 +02:00
|
|
|
const paragraphNode = { type: { name: 'paragraph', spec: {} } }
|
2026-04-23 22:06:01 +02:00
|
|
|
const view = {
|
2026-05-01 13:26:29 +02:00
|
|
|
dom: {
|
|
|
|
|
isConnected: true,
|
|
|
|
|
},
|
2026-04-23 22:06:01 +02:00
|
|
|
dispatch: vi.fn(),
|
|
|
|
|
state: {
|
|
|
|
|
doc: {
|
|
|
|
|
textBetween: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
selection: {
|
|
|
|
|
from: 2,
|
|
|
|
|
to: 2,
|
2026-04-29 17:12:29 +02:00
|
|
|
$from: {
|
|
|
|
|
depth: 0,
|
|
|
|
|
node: vi.fn(() => paragraphNode),
|
|
|
|
|
},
|
2026-04-23 22:06:01 +02:00
|
|
|
},
|
|
|
|
|
tr: transaction,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
const dom = {
|
|
|
|
|
addEventListener: vi.fn((type: string, listener: (event: InputEvent) => void) => {
|
|
|
|
|
if (type === 'beforeinput') {
|
|
|
|
|
beforeInputListener = listener
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
const editor = {
|
|
|
|
|
_tiptapEditor: { view },
|
|
|
|
|
prosemirrorView: view,
|
|
|
|
|
}
|
|
|
|
|
const extension = createArrowLigaturesExtension()({ editor: editor as never })
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
dom,
|
|
|
|
|
editor,
|
|
|
|
|
extension,
|
|
|
|
|
fireInput(event: Partial<InputEvent> = {}) {
|
|
|
|
|
if (!beforeInputListener) {
|
|
|
|
|
throw new Error('Arrow ligatures extension did not register a beforeinput listener')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inputEvent = {
|
|
|
|
|
data: '>',
|
|
|
|
|
inputType: 'insertText',
|
|
|
|
|
preventDefault: vi.fn(),
|
|
|
|
|
...event,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeInputListener(inputEvent as InputEvent)
|
|
|
|
|
return inputEvent
|
|
|
|
|
},
|
|
|
|
|
mount() {
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
extension.mount?.({
|
|
|
|
|
dom: dom as never,
|
|
|
|
|
root: document,
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
return controller
|
|
|
|
|
},
|
|
|
|
|
transaction,
|
|
|
|
|
view,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('createArrowLigaturesExtension', () => {
|
|
|
|
|
it('registers a beforeinput listener when the editor mounts', () => {
|
|
|
|
|
const fixture = createFixture()
|
|
|
|
|
|
|
|
|
|
fixture.mount()
|
|
|
|
|
|
|
|
|
|
expect(fixture.dom.addEventListener).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(fixture.dom.addEventListener).toHaveBeenCalledWith(
|
|
|
|
|
'beforeinput',
|
|
|
|
|
expect.any(Function),
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
capture: true,
|
|
|
|
|
signal: expect.any(AbortSignal),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('replaces completed ASCII arrows through the mounted input listener', () => {
|
|
|
|
|
const fixture = createFixture()
|
|
|
|
|
fixture.mount()
|
|
|
|
|
fixture.view.state.doc.textBetween.mockReturnValue('-')
|
|
|
|
|
|
|
|
|
|
const event = fixture.fireInput()
|
|
|
|
|
|
|
|
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(fixture.transaction.insertText).toHaveBeenCalledWith('→', 1, 2)
|
|
|
|
|
expect(fixture.view.dispatch).toHaveBeenCalledWith(fixture.transaction)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('preserves escaped <-> as literal ASCII on the next keystroke', () => {
|
|
|
|
|
const fixture = createFixture()
|
|
|
|
|
fixture.mount()
|
|
|
|
|
fixture.view.state.selection = { from: 3, to: 3 }
|
|
|
|
|
|
|
|
|
|
fixture.view.state.doc.textBetween.mockReturnValueOnce('\\<')
|
|
|
|
|
fixture.fireInput({ data: '-' })
|
|
|
|
|
|
|
|
|
|
fixture.view.state.doc.textBetween.mockReturnValueOnce('<-')
|
|
|
|
|
const event = fixture.fireInput({ data: '>' })
|
|
|
|
|
|
|
|
|
|
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
|
|
|
expect(fixture.transaction.insertText).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(fixture.transaction.insertText).toHaveBeenCalledWith('<-', 1, 3)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('ignores non-text input so paste stays literal', () => {
|
|
|
|
|
const fixture = createFixture()
|
|
|
|
|
fixture.mount()
|
|
|
|
|
|
|
|
|
|
const event = fixture.fireInput({ inputType: 'insertFromPaste' })
|
|
|
|
|
|
|
|
|
|
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
|
|
|
expect(fixture.transaction.insertText).not.toHaveBeenCalled()
|
|
|
|
|
expect(fixture.view.dispatch).not.toHaveBeenCalled()
|
|
|
|
|
})
|
2026-04-24 02:50:12 +02:00
|
|
|
|
2026-04-29 17:12:29 +02:00
|
|
|
it('does not replace arrows while typing inside code blocks', () => {
|
|
|
|
|
const fixture = createFixture()
|
|
|
|
|
fixture.mount()
|
|
|
|
|
fixture.view.state.doc.textBetween.mockReturnValue('-')
|
|
|
|
|
fixture.view.state.selection.$from.node.mockReturnValue({
|
|
|
|
|
type: { name: 'codeBlock', spec: { code: true } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const event = fixture.fireInput()
|
|
|
|
|
|
|
|
|
|
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
|
|
|
expect(fixture.transaction.insertText).not.toHaveBeenCalled()
|
|
|
|
|
expect(fixture.view.dispatch).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-24 02:50:12 +02:00
|
|
|
it('ignores composing input so IME text is not rewritten', () => {
|
|
|
|
|
const fixture = createFixture()
|
|
|
|
|
fixture.mount()
|
|
|
|
|
|
|
|
|
|
const event = fixture.fireInput({ isComposing: true })
|
|
|
|
|
|
|
|
|
|
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
|
|
|
expect(fixture.transaction.insertText).not.toHaveBeenCalled()
|
|
|
|
|
expect(fixture.view.dispatch).not.toHaveBeenCalled()
|
|
|
|
|
})
|
2026-05-01 13:26:29 +02:00
|
|
|
|
|
|
|
|
it('falls through when a reload leaves the ProseMirror view stale during beforeinput', () => {
|
|
|
|
|
const fixture = createFixture()
|
|
|
|
|
fixture.mount()
|
|
|
|
|
Object.defineProperty(fixture.view, 'state', {
|
|
|
|
|
configurable: true,
|
|
|
|
|
get: () => {
|
|
|
|
|
throw new Error('stale editor view')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(() => fixture.fireInput()).not.toThrow()
|
|
|
|
|
})
|
2026-04-23 22:06:01 +02:00
|
|
|
})
|