// src/plugin/__tests__/editor-suggest-pointer.test.ts import { 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 = null let harness2: MountedHarnessBase | null = null afterEach(() => { harness?.destroy() harness2?.destroy() harness = null harness2 = null document.body.innerHTML = '' }) class BracketSuggest extends EditorSuggest { 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 { 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) }) })