import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { uiHooks } from '@/lib/plugin-hooks'; import { bodyMenuPoint, selectedTextIn, watchBodySelection, watchElementSelection } from '../reader-selection-menu'; // Selected text inside a message body, for plugins. An HTML body is a // sandboxed iframe: its selection never reaches the page's selectionchange, // so the body document itself is watched. function bodyFrame(html: string) { const iframe = document.createElement('iframe'); document.body.appendChild(iframe); const doc = iframe.contentDocument!; doc.body.innerHTML = html; return { iframe, doc }; } function selectAll(doc: Document, el: Element) { const range = doc.createRange(); range.selectNodeContents(el); const sel = doc.getSelection()!; sel.removeAllRanges(); sel.addRange(range); } beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); document.body.innerHTML = ''; }); describe('watchBodySelection', () => { it('reports the selected text a moment after it changes, once per change, and tells the plugins', async () => { const { iframe, doc } = bodyFrame('
Quoted words
'); const onSelection = vi.fn(); const heard = vi.fn(); const handler = uiHooks.onTextSelectionChange.register('p1', heard); try { watchBodySelection(doc, iframe, { emailId: 'e1', onSelection }); selectAll(doc, doc.querySelector('p')!); doc.dispatchEvent(new Event('selectionchange')); doc.dispatchEvent(new Event('selectionchange')); expect(onSelection).not.toHaveBeenCalled(); vi.advanceTimersByTime(200); expect(onSelection).toHaveBeenCalledTimes(1); expect(onSelection).toHaveBeenCalledWith('Quoted words'); await Promise.resolve(); expect(heard).toHaveBeenCalledWith({ text: 'Quoted words', source: 'email-body', emailId: 'e1' }); // The same selection again is not reported again. doc.dispatchEvent(new Event('selectionchange')); vi.advanceTimersByTime(200); expect(onSelection).toHaveBeenCalledTimes(1); // Gone: reported as empty, but plugins are not told about nothing. doc.getSelection()!.removeAllRanges(); doc.dispatchEvent(new Event('selectionchange')); vi.advanceTimersByTime(200); expect(onSelection).toHaveBeenLastCalledWith(''); await Promise.resolve(); expect(heard).toHaveBeenCalledTimes(1); } finally { handler.dispose(); } }); it('opens the menu on a right-click on selected text, at the frame offset, and keeps the browser menu otherwise', () => { const { iframe, doc } = bodyFrame('Quoted words
'); iframe.getBoundingClientRect = () => ({ left: 100, top: 50, width: 600, height: 400, right: 700, bottom: 450, x: 100, y: 50, toJSON: () => ({}) }); Object.defineProperty(doc.documentElement, 'clientWidth', { value: 600, configurable: true }); const onContextMenu = vi.fn().mockReturnValue(true); watchBodySelection(doc, iframe, { emailId: 'e1', onContextMenu }); // No selection: nothing happens. const idle = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true }); doc.dispatchEvent(idle); expect(onContextMenu).not.toHaveBeenCalled(); expect(idle.defaultPrevented).toBe(false); selectAll(doc, doc.querySelector('p')!); const click = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true }); doc.dispatchEvent(click); expect(onContextMenu).toHaveBeenCalledWith({ x: 110, y: 70 }, 'Quoted words'); expect(click.defaultPrevented).toBe(true); // The menu declined (no plugin entries): the browser's own menu stays. onContextMenu.mockReturnValue(false); const declined = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true }); doc.dispatchEvent(declined); expect(declined.defaultPrevented).toBe(false); }); it('scales the point when the body was shrunk to fit a narrow screen', () => { const { iframe, doc } = bodyFrame('x
'); iframe.getBoundingClientRect = () => ({ left: 0, top: 100, width: 300, height: 400, right: 300, bottom: 500, x: 0, y: 100, toJSON: () => ({}) }); Object.defineProperty(doc.documentElement, 'clientWidth', { value: 600, configurable: true }); expect(bodyMenuPoint({ clientX: 200, clientY: 40 }, doc, iframe)).toEqual({ x: 100, y: 120 }); }); it('stops when told to', () => { const { iframe, doc } = bodyFrame('Quoted words
'); const onSelection = vi.fn(); const stop = watchBodySelection(doc, iframe, { emailId: 'e1', onSelection }); stop(); selectAll(doc, doc.querySelector('p')!); doc.dispatchEvent(new Event('selectionchange')); vi.advanceTimersByTime(200); expect(onSelection).not.toHaveBeenCalled(); }); }); describe('a plain-text body in the page', () => { it('counts only a selection inside the body', () => { document.body.innerHTML = 'Plain words
Plain words