Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350// src/plugin/__tests__/editor-suggest-pointer.test.tsimport { describe, it, expect, afterEach, vi } from 'vitest'import { EditorSuggest } from '../editor-suggest'import { mountCM6EditorWithSourceMode, mountCM6EditorIntoApp, typeText, findDropdown, type MountedHarness, type MountedHarnessBase,} from './editor-suggest-test-harness'import { getDispatcherState } from '../../editor/extensions/editor-suggest-dispatcher'
let harness: MountedHarness | null = nulllet harness2: MountedHarnessBase | null = null
afterEach(() => { harness?.destroy() harness2?.destroy() harness = null harness2 = null document.body.innerHTML = ''})
class BracketSuggest extends EditorSuggest<string> { selectCalls: Array<{ item: string; evt: Event | null }> = [] onTrigger(cursor: { line: number; ch: number }, editor: { getLine(line: number): string }) { const line = editor.getLine(cursor.line) const before = line.slice(0, cursor.ch) const m = /\[\[([^\]\[]*)$/.exec(before) if (!m) return null return { start: { line: cursor.line, ch: m.index }, end: { line: cursor.line, ch: cursor.ch }, query: m[1], } } async getSuggestions() { return ['alpha', 'bravo', 'charlie'] } renderSuggestion(item: string, el: HTMLElement) { el.textContent = item } selectSuggestion(item: string, evt: MouseEvent | KeyboardEvent) { this.selectCalls.push({ item, evt }) }}
async function flush(): Promise<void> { await new Promise((r) => setTimeout(r, 80))}
describe('editor-suggest pointer / mobile / outside-click', () => { // Regression: the counter-based dispatcherId (replacing randomUUID for // non-secure contexts) must still correctly identify the dropdown so // outside-click closes it. Previously a randomUUID call would throw in // LAN-IP access (http://192.168.x.x), preventing the dispatcher from // constructing at all. it('outside-click closes dropdown when crypto.randomUUID is unavailable (LAN-IP regression)', async () => { const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto const originalRandomUUID = cryptoObj?.randomUUID if (cryptoObj && typeof cryptoObj.randomUUID === 'function') { // Mimic Firefox non-secure context: randomUUID property absent. delete (cryptoObj as unknown as { randomUUID?: unknown }).randomUUID } try { const h = await mountCM6EditorWithSourceMode() harness = h const sug = new BracketSuggest(h.app) h.app.workspace.registerEditorSuggest(sug) typeText(h.view, '[[') await flush() const dropdown = findDropdown() expect(dropdown).not.toBeNull() // The dropdown's data-dispatcher-id attribute should be present, // even though randomUUID wasn't available. expect(dropdown?.dataset.dispatcherId).toBeDefined() expect(dropdown?.dataset.dispatcherId).toMatch(/^dispatcher-\d+$/) // Outside-click must still close the dropdown. const outside = document.createElement('div') document.body.appendChild(outside) outside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })) expect(findDropdown()).toBeNull() outside.remove() } finally { if (cryptoObj && originalRandomUUID) { ;(cryptoObj as unknown as { randomUUID: typeof originalRandomUUID }).randomUUID = originalRandomUUID } } })
it('Click on dropdown item inserts and closes', async () => { const h = await mountCM6EditorWithSourceMode() harness = h const sug = new BracketSuggest(h.app) h.app.workspace.registerEditorSuggest(sug) typeText(h.view, '[[') await flush() const dropdown = findDropdown()! expect(dropdown).not.toBeNull() const items = dropdown.querySelectorAll('.cm-editor-suggest-item') const second = items[1] as HTMLElement second.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) expect(sug.selectCalls.length).toBe(1) expect(sug.selectCalls[0].item).toBe('bravo') expect(findDropdown()).toBeNull() })
it('selects on touchend when mobile Safari drops the synthesized click', async () => { const h = await mountCM6EditorWithSourceMode() harness = h const sug = new BracketSuggest(h.app) h.app.workspace.registerEditorSuggest(sug) typeText(h.view, '[[') await flush() const dropdown = findDropdown()! const items = dropdown.querySelectorAll('.cm-editor-suggest-item') const first = items[0] as HTMLElement first.dispatchEvent(new Event('touchend', { bubbles: true, cancelable: true }))
expect(sug.selectCalls.length).toBe(1) expect(findDropdown()).toBeNull() })
it('does not select on touch pointerdown so the mobile list can be panned', async () => { const h = await mountCM6EditorWithSourceMode() harness = h const sug = new BracketSuggest(h.app) h.app.workspace.registerEditorSuggest(sug) typeText(h.view, '[[') await flush() const dropdown = findDropdown()! const first = dropdown.querySelector('.cm-editor-suggest-item') as HTMLElement
first.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerType: 'touch', }))
expect(sug.selectCalls).toEqual([]) expect(findDropdown()).toBe(dropdown) })
it('does not select after a touchmove, preserving scroll gestures inside the list', async () => { const h = await mountCM6EditorWithSourceMode() harness = h const sug = new BracketSuggest(h.app) h.app.workspace.registerEditorSuggest(sug) typeText(h.view, '[[') await flush() const dropdown = findDropdown()! const items = dropdown.querySelectorAll('.cm-editor-suggest-item') const first = items[0] as HTMLElement const second = items[1] as HTMLElement
first.dispatchEvent(new Event('touchstart', { bubbles: true, cancelable: true })) first.dispatchEvent(new Event('touchmove', { bubbles: true, cancelable: true })) first.dispatchEvent(new Event('touchend', { bubbles: true, cancelable: true })) second.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
expect(sug.selectCalls).toEqual([]) expect(findDropdown()).toBe(dropdown)
await new Promise((r) => setTimeout(r, 0)) second.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
expect(sug.selectCalls).toHaveLength(1) expect(sug.selectCalls[0].item).toBe('bravo') expect(findDropdown()).toBeNull() })
it('does not select after a touch pointer pan followed by a compatibility click', async () => { const h = await mountCM6EditorWithSourceMode() harness = h const sug = new BracketSuggest(h.app) h.app.workspace.registerEditorSuggest(sug) typeText(h.view, '[[') await flush() const dropdown = findDropdown()! const first = dropdown.querySelector('.cm-editor-suggest-item') as HTMLElement
vi.useFakeTimers() try { first.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerType: 'touch', clientX: 0, clientY: 0, })) first.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerType: 'touch', clientX: 0, clientY: 30, })) first.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerType: 'touch', clientX: 0, clientY: 30, })) await vi.advanceTimersByTimeAsync(50) first.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
expect(sug.selectCalls).toEqual([]) expect(findDropdown()).toBe(dropdown) } finally { vi.useRealTimers() } })
it('still treats small touch jitter as a tap', async () => { const h = await mountCM6EditorWithSourceMode() harness = h const sug = new BracketSuggest(h.app) h.app.workspace.registerEditorSuggest(sug) typeText(h.view, '[[') await flush() const dropdown = findDropdown()! const first = dropdown.querySelector('.cm-editor-suggest-item') as HTMLElement
first.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerType: 'touch', clientX: 10, clientY: 10, })) first.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerType: 'touch', clientX: 13, clientY: 13, })) first.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerType: 'touch', clientX: 13, clientY: 13, })) first.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
expect(sug.selectCalls).toHaveLength(1) expect(sug.selectCalls[0].item).toBe('alpha') expect(findDropdown()).toBeNull() })
it('sets fixed positioning and pan-y touch-action for mobile gestures', async () => { const h = await mountCM6EditorWithSourceMode() harness = h const sug = new BracketSuggest(h.app) h.app.workspace.registerEditorSuggest(sug) typeText(h.view, '[[') await flush() const dropdown = findDropdown()! const first = dropdown.querySelector('.cm-editor-suggest-item') as HTMLElement
expect(dropdown.style.position).toBe('fixed') expect(dropdown.style.touchAction).toBe('pan-y') expect(first.style.touchAction).toBe('pan-y') })
it('outside-click via mousedown closes the dropdown', async () => { const h = await mountCM6EditorWithSourceMode() harness = h const sug = new BracketSuggest(h.app) h.app.workspace.registerEditorSuggest(sug) typeText(h.view, '[[') await flush() expect(findDropdown()).not.toBeNull() // Dispatch a mousedown on body (outside both the editor contentDOM and // the dropdown). const outside = document.createElement('div') document.body.appendChild(outside) outside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })) expect(findDropdown()).toBeNull() outside.remove() })
it('multi-editor isolation: TWO editors sharing ONE app have independent dropdowns and state', async () => { // Critical: ONE app, ONE workspace registry, TWO CM6Editor instances. // The earlier flawed test created two separate apps which trivially // pass because each had its own registry. const h1 = await mountCM6EditorWithSourceMode() const sharedApp = h1.app const h2 = mountCM6EditorIntoApp(sharedApp) harness = h1 harness2 = h2
// ONE suggester registered ONCE on the shared workspace. const sug = new BracketSuggest(sharedApp) sharedApp.workspace.registerEditorSuggest(sug) expect(sharedApp.workspace.editorSuggests.length).toBe(1)
// Step 1: type [[ in editor A — dropdown A appears. typeText(h1.view, '[[') await flush() expect(document.querySelectorAll('.cm-editor-suggest-dropdown').length).toBe(1) const stateA1 = getDispatcherState(h1.view, sharedApp) expect(stateA1?.dropdownEl).not.toBeNull() const idA = stateA1!.dispatcherId
// Step 2: type [[ in editor B — dropdown B appears AND dropdown A still open. typeText(h2.view, '[[') await flush() const all = document.querySelectorAll('.cm-editor-suggest-dropdown') expect(all.length).toBe(2) const stateB1 = getDispatcherState(h2.view, sharedApp) const idB = stateB1!.dispatcherId expect(idA).not.toBe(idB)
// Per-dispatcher controllers are independent. expect(stateA1!.controller).not.toBe(stateB1!.controller)
// Step 3: click outside both editors AND both dropdowns → both close. const outside = document.createElement('div') document.body.appendChild(outside) outside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })) expect(document.querySelectorAll('.cm-editor-suggest-dropdown').length).toBe(0) outside.remove()
// Step 4: re-open BOTH. typeText(h1.view, 'a') await flush() typeText(h2.view, 'b') await flush() expect(document.querySelectorAll('.cm-editor-suggest-dropdown').length).toBe(2)
// Step 5: click on a dropdown A item — A's selectSuggestion fires; // dropdown A removed (selection path); dropdown B also removed // (outside-click path — clicking inside A's dropdown is OUTSIDE for B). const dropA = document.querySelector( `[data-dispatcher-id="${idA}"]`, ) as HTMLElement expect(dropA).not.toBeNull() const itemA = dropA.querySelector('.cm-editor-suggest-item') as HTMLElement itemA.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })) itemA.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
expect(sug.selectCalls.length).toBeGreaterThanOrEqual(1) expect(document.querySelectorAll('.cm-editor-suggest-dropdown').length).toBe(0) })})