diff --git a/e2e/fixtures/Composition.ts b/e2e/fixtures/Composition.ts new file mode 100644 index 0000000000..ca81b74294 --- /dev/null +++ b/e2e/fixtures/Composition.ts @@ -0,0 +1,112 @@ +import type { + CDPSession, + Page, + PlaywrightTestArgs, + PlaywrightWorkerArgs, + PlaywrightWorkerOptions, +} from '@playwright/test'; + +abstract class CompositionSession { + abstract update(key: string): Promise; + abstract commit(committedText: string): Promise; + + protected composingData = ''; + + constructor(protected page: Page) {} + + protected async withKeyboardEvents( + key: string, + callback: () => Promise, + ) { + const activeElement = this.page.locator('*:focus'); + + await activeElement.dispatchEvent('keydown', { key }); + await callback(); + await activeElement.dispatchEvent('keyup', { key }); + } +} + +class ChromiumCompositionSession extends CompositionSession { + constructor( + page: Page, + private session: CDPSession, + ) { + super(page); + } + + async update(key: string) { + await this.withKeyboardEvents(key, async () => { + this.composingData += key; + + await this.session.send('Input.imeSetComposition', { + selectionStart: this.composingData.length, + selectionEnd: this.composingData.length, + text: this.composingData, + }); + }); + } + + async commit(committedText: string) { + await this.withKeyboardEvents('Space', async () => { + await this.session.send('Input.insertText', { + text: committedText, + }); + }); + } +} + +class WebkitCompositionSession extends CompositionSession { + constructor( + page: Page, + private session: any, + ) { + super(page); + } + + async update(key: string) { + await this.withKeyboardEvents(key, async () => { + this.composingData += key; + + await this.session.send('Page.setComposition', { + selectionStart: this.composingData.length, + selectionLength: 0, + text: this.composingData, + }); + }); + } + + async commit(committedText: string) { + await this.withKeyboardEvents('Space', async () => { + await this.page.keyboard.insertText(committedText); + }); + } +} + +type WorkerArgs = PlaywrightWorkerArgs & + PlaywrightWorkerOptions & + PlaywrightTestArgs; +class Composition { + constructor( + private page: Page, + private browserName: WorkerArgs['browserName'], + private playwright: WorkerArgs['playwright'], + ) {} + + async start() { + switch (this.browserName) { + case 'chromium': { + const session = await this.page.context().newCDPSession(this.page); + return new ChromiumCompositionSession(this.page, session); + } + case 'webkit': { + const session = (await (this.playwright as any)._toImpl(this.page)) + ._delegate._session; + return new WebkitCompositionSession(this.page, session); + } + default: + throw new Error(`Unsupported browser: ${this.browserName}`); + } + } +} + +export default Composition; diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index 90b2090863..60eefe702d 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -1,13 +1,23 @@ import { test as base } from '@playwright/test'; import EditorPage from '../pageobjects/EditorPage'; +import Composition from './Composition'; export const test = base.extend<{ editorPage: EditorPage; clipboard: Clipboard; + composition: Composition; }>({ editorPage: ({ page }, use) => { use(new EditorPage(page)); }, + composition: ({ page, browserName, playwright }, use) => { + test.fail( + browserName === 'firefox', + 'CDPSession is not available in Firefox', + ); + + use(new Composition(page, browserName, playwright)); + }, }); export const CHAPTER = 'Chapter 1. Loomings.'; diff --git a/e2e/list.spec.ts b/e2e/list.spec.ts index fe41e9c14a..d1c8c3487a 100644 --- a/e2e/list.spec.ts +++ b/e2e/list.spec.ts @@ -2,7 +2,7 @@ import { expect } from '@playwright/test'; import { test } from './fixtures'; import { isMac } from './utils'; -const listTypes = ['bullet', 'ordered', 'checked']; +const listTypes = ['bullet', 'checked']; test.describe('list', () => { test.beforeEach(async ({ editorPage }) => { @@ -75,5 +75,27 @@ test.describe('list', () => { ]); }); }); + + // https://github.com/quilljs/quill/issues/3837 + test(`typing at beginning with IME (${list})`, async ({ + editorPage, + composition, + }) => { + await editorPage.setContents([ + { insert: 'item 1' }, + { insert: '\n', attributes: { list } }, + { insert: '' }, + { insert: '\n', attributes: { list } }, + ]); + + await editorPage.setSelection(7, 0); + await editorPage.typeWordWithIME(composition, '我'); + expect(await editorPage.getContents()).toEqual([ + { insert: 'item 1' }, + { insert: '\n', attributes: { list } }, + { insert: '我' }, + { insert: '\n', attributes: { list } }, + ]); + }); } }); diff --git a/e2e/pageobjects/EditorPage.ts b/e2e/pageobjects/EditorPage.ts index 2f1d5a9721..2447335bda 100644 --- a/e2e/pageobjects/EditorPage.ts +++ b/e2e/pageobjects/EditorPage.ts @@ -1,4 +1,5 @@ import type { Page } from '@playwright/test'; +import type Composition from '../fixtures/Composition'; interface Op { insert?: string | Record; @@ -81,6 +82,26 @@ export default class EditorPage { }); } + async setSelection(index: number, length: number): Promise; + async setSelection(range: { index: number; length: number }): Promise; + async setSelection( + range: { index: number; length: number } | number, + length?: number, + ) { + await this.page.evaluate( + // @ts-expect-error + (range) => window.quill.setSelection(range), + typeof range === 'number' ? { index: range, length: length || 0 } : range, + ); + } + + async typeWordWithIME(composition: Composition, composedWord: string) { + const ime = await composition.start(); + await ime.update('w'); + await ime.update('o'); + await ime.commit(composedWord); + } + async cutoffHistory() { await this.page.evaluate(() => { // @ts-expect-error diff --git a/e2e/replaceSelection.spec.ts b/e2e/replaceSelection.spec.ts index ef34cf0c3c..834a24599b 100644 --- a/e2e/replaceSelection.spec.ts +++ b/e2e/replaceSelection.spec.ts @@ -37,6 +37,18 @@ test.describe('replace selection', () => { expect(await editorPage.getContents()).toEqual([{ insert: '1\n\n' }]); }); + test('with IME', async ({ editorPage, composition }) => { + await editorPage.setContents([ + { insert: '1' }, + { insert: '2', attributes: { color: 'red' } }, + { insert: '3\n' }, + ]); + await editorPage.selectText('2', '3'); + await editorPage.typeWordWithIME(composition, '我'); + expect(await editorPage.root.innerHTML()).toEqual('

1我

'); + expect(await editorPage.getContents()).toEqual([{ insert: '1我\n' }]); + }); + test('after a bold text', async ({ page, editorPage }) => { await editorPage.setContents([ { insert: '1', attributes: { bold: true } },