From 1c2521ee7039aadde9cc670214de60056afc1cfa Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Tue, 20 Jun 2023 15:00:10 +0800 Subject: [PATCH] Handle text replacements explicitly --- core.ts | 2 + core/composition.ts | 44 +++++ core/emitter.ts | 4 + core/quill.ts | 13 +- core/selection.ts | 8 +- e2e/{utils/fixtures.ts => fixtures/index.ts} | 12 ++ e2e/full.spec.ts | 39 ++-- e2e/pageobjects/EditorPage.ts | 182 +++++++++++++++++++ e2e/replaceSelection.spec.ts | 75 ++++++++ e2e/utils/QuillPage.ts | 15 -- modules/input.ts | 103 +++++++++++ 11 files changed, 456 insertions(+), 41 deletions(-) create mode 100644 core/composition.ts rename e2e/{utils/fixtures.ts => fixtures/index.ts} (85%) create mode 100644 e2e/pageobjects/EditorPage.ts create mode 100644 e2e/replaceSelection.spec.ts delete mode 100644 e2e/utils/QuillPage.ts create mode 100644 modules/input.ts diff --git a/core.ts b/core.ts index 2314feec85..95009c43ff 100644 --- a/core.ts +++ b/core.ts @@ -14,6 +14,7 @@ import History from './modules/history'; import Keyboard from './modules/keyboard'; import Uploader from './modules/uploader'; import Delta, { Op, OpIterator, AttributeMap } from 'quill-delta'; +import Input from './modules/input'; export { Delta, Op, OpIterator, AttributeMap }; @@ -32,6 +33,7 @@ Quill.register({ 'modules/history': History, 'modules/keyboard': Keyboard, 'modules/uploader': Uploader, + 'modules/input': Input, }); export default Quill; diff --git a/core/composition.ts b/core/composition.ts new file mode 100644 index 0000000000..98c4ba51b9 --- /dev/null +++ b/core/composition.ts @@ -0,0 +1,44 @@ +import Embed from '../blots/embed'; +import Scroll from '../blots/scroll'; +import Emitter from './emitter'; + +class Composition { + isComposing = false; + + constructor(private scroll: Scroll, private emitter: Emitter) { + scroll.domNode.addEventListener('compositionstart', event => { + if (!this.isComposing) { + this.handleCompositionStart(event); + } + }); + + scroll.domNode.addEventListener('compositionend', event => { + if (this.isComposing) { + this.handleCompositionEnd(event); + } + }); + } + + private handleCompositionStart(event: CompositionEvent) { + const blot = + event.target instanceof Node + ? this.scroll.find(event.target, true) + : null; + + if (blot && !(blot instanceof Embed)) { + this.emitter.emit(Emitter.events.COMPOSITION_BEFORE_START, event); + this.scroll.batchStart(); + this.emitter.emit(Emitter.events.COMPOSITION_START, event); + this.isComposing = true; + } + } + + private handleCompositionEnd(event: CompositionEvent) { + this.emitter.emit(Emitter.events.COMPOSITION_BEFORE_END, event); + this.scroll.batchEnd(); + this.emitter.emit(Emitter.events.COMPOSITION_END, event); + this.isComposing = false; + } +} + +export default Composition; diff --git a/core/emitter.ts b/core/emitter.ts index c6fac151c9..414f8fa380 100644 --- a/core/emitter.ts +++ b/core/emitter.ts @@ -27,6 +27,10 @@ class Emitter extends EventEmitter { SCROLL_EMBED_UPDATE: 'scroll-embed-update', SELECTION_CHANGE: 'selection-change', TEXT_CHANGE: 'text-change', + COMPOSITION_BEFORE_START: 'composition-before-start', + COMPOSITION_START: 'composition-start', + COMPOSITION_BEFORE_END: 'composition-before-end', + COMPOSITION_END: 'composition-end', } as const; static sources = { diff --git a/core/quill.ts b/core/quill.ts index 16e83eb213..0f9a1f7c69 100644 --- a/core/quill.ts +++ b/core/quill.ts @@ -14,6 +14,7 @@ import instances from './instances'; import logger, { DebugLevel } from './logger'; import Module from './module'; import Selection, { Range } from './selection'; +import Composition from './composition'; import Theme, { ThemeConstructor } from './theme'; const debug = logger('quill'); @@ -135,6 +136,7 @@ class Quill { emitter: Emitter; allowReadOnlyEdits: boolean; editor: Editor; + composition: Composition; selection: Selection; theme: Theme; @@ -172,11 +174,13 @@ class Quill { }); this.editor = new Editor(this.scroll); this.selection = new Selection(this.scroll, this.emitter); + this.composition = new Composition(this.scroll, this.emitter); this.theme = new this.options.theme(this, this.options); // eslint-disable-line new-cap this.keyboard = this.theme.addModule('keyboard'); this.clipboard = this.theme.addModule('clipboard'); this.history = this.theme.addModule('history'); this.uploader = this.theme.addModule('uploader'); + this.theme.addModule('input'); this.theme.init(); this.emitter.on(Emitter.events.EDITOR_CHANGE, type => { if (type === Emitter.events.TEXT_CHANGE) { @@ -528,6 +532,12 @@ class Quill { } insertText(index: number, text: string, source: EmitterSource): Delta; + insertText( + index: number, + text: string, + formats: Record, + source: EmitterSource, + ): Delta; insertText( index: number, text: string, @@ -538,12 +548,13 @@ class Quill { insertText( index: number, text: string, - name: string | EmitterSource, + name: string | Record | EmitterSource, value?: unknown, source?: EmitterSource, ): Delta { let formats; // eslint-disable-next-line prefer-const + // @ts-expect-error [index, , formats, source] = overload(index, 0, name, value, source); return modify.call( this, diff --git a/core/selection.ts b/core/selection.ts index 38ebf590f4..430418b32e 100644 --- a/core/selection.ts +++ b/core/selection.ts @@ -8,7 +8,7 @@ import Scroll from '../blots/scroll'; const debug = logger('quill:selection'); -type NativeRange = ReturnType; +type NativeRange = AbstractRange; interface NormalizedRange { start: { @@ -95,12 +95,10 @@ class Selection { } handleComposition() { - this.root.addEventListener('compositionstart', () => { + this.emitter.on(Emitter.events.COMPOSITION_BEFORE_START, () => { this.composing = true; - this.scroll.batchStart(); }); - this.root.addEventListener('compositionend', () => { - this.scroll.batchEnd(); + this.emitter.on(Emitter.events.COMPOSITION_END, () => { this.composing = false; if (this.cursor.parent) { const range = this.cursor.restore(); diff --git a/e2e/utils/fixtures.ts b/e2e/fixtures/index.ts similarity index 85% rename from e2e/utils/fixtures.ts rename to e2e/fixtures/index.ts index 20a0e7aa84..90b2090863 100644 --- a/e2e/utils/fixtures.ts +++ b/e2e/fixtures/index.ts @@ -1,3 +1,15 @@ +import { test as base } from '@playwright/test'; +import EditorPage from '../pageobjects/EditorPage'; + +export const test = base.extend<{ + editorPage: EditorPage; + clipboard: Clipboard; +}>({ + editorPage: ({ page }, use) => { + use(new EditorPage(page)); + }, +}); + export const CHAPTER = 'Chapter 1. Loomings.'; export const P1 = 'Call me Ishmael. Some years ago—never mind how long precisely-having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking people’s hats off—then, I account it high time to get to sea as soon as I can. This is my substitute for pistol and ball. With a philosophical flourish Cato throws himself upon his sword; I quietly take to the ship. There is nothing surprising in this. If they but knew it, almost all men in their degree, some time or other, cherish very nearly the same feelings towards the ocean with me.'; diff --git a/e2e/full.spec.ts b/e2e/full.spec.ts index 048a13a412..c89f400b9f 100644 --- a/e2e/full.spec.ts +++ b/e2e/full.spec.ts @@ -1,19 +1,18 @@ -import { test, expect } from '@playwright/test'; +import { expect } from '@playwright/test'; import { getSelectionInTextNode, SHORTKEY } from './utils'; -import { CHAPTER, P1, P2 } from './utils/fixtures'; -import QuillPage from './utils/QuillPage'; +import { test, CHAPTER, P1, P2 } from './fixtures'; -test('compose an epic', async ({ page }) => { - await page.goto('http://localhost:9000/standalone/full'); - const quillPage = new QuillPage(page); - await page.waitForSelector('.ql-editor', { timeout: 10000 }); +test('compose an epic', async ({ page, editorPage }) => { + await editorPage.open(); await expect(page).toHaveTitle('Full Editor - Quill Rich Text Editor'); await page.type('.ql-editor', 'The Whale'); - expect(await quillPage.editorHTML()).toEqual('

The Whale

'); + expect(await editorPage.root.innerHTML()).toEqual('

The Whale

'); await page.keyboard.press('Enter'); - expect(await quillPage.editorHTML()).toEqual('

The Whale


'); + expect(await editorPage.root.innerHTML()).toEqual( + '

The Whale


', + ); await page.keyboard.press('Enter'); await page.keyboard.press('Tab'); @@ -21,7 +20,7 @@ test('compose an epic', async ({ page }) => { await page.keyboard.press('Enter'); await page.keyboard.press('Enter'); await page.type('.ql-editor', P2); - expect(await quillPage.editorHTML()).toEqual( + expect(await editorPage.root.innerHTML()).toEqual( [ '

The Whale

', '


', @@ -41,7 +40,7 @@ test('compose an epic', async ({ page }) => { await page.keyboard.press('Enter'); await page.type('.ql-editor', CHAPTER); await page.keyboard.press('Enter'); - expect(await quillPage.editorHTML()).toEqual( + expect(await editorPage.root.innerHTML()).toEqual( [ '

The Whale

', '


', @@ -67,7 +66,7 @@ test('compose an epic', async ({ page }) => { await page.keyboard.press('Backspace'); await page.keyboard.press('Backspace'); await page.keyboard.press('Backspace'); - expect(await quillPage.editorHTML()).toEqual( + expect(await editorPage.root.innerHTML()).toEqual( [ '

Whale

', '


', @@ -84,7 +83,7 @@ test('compose an epic', async ({ page }) => { await page.keyboard.press('Delete'); await page.keyboard.press('Delete'); await page.keyboard.press('Delete'); - expect(await quillPage.editorHTML()).toEqual( + expect(await editorPage.root.innerHTML()).toEqual( [ '


', '


', @@ -97,7 +96,7 @@ test('compose an epic', async ({ page }) => { ); await page.keyboard.press('Delete'); - expect(await quillPage.editorHTML()).toEqual( + expect(await editorPage.root.innerHTML()).toEqual( [ '


', `

${CHAPTER}

`, @@ -110,7 +109,7 @@ test('compose an epic', async ({ page }) => { await page.click('.ql-toolbar .ql-bold'); await page.click('.ql-toolbar .ql-italic'); - expect(await quillPage.editorHTML()).toEqual( + expect(await editorPage.root.innerHTML()).toEqual( [ '

\uFEFF

', `

${CHAPTER}

`, @@ -126,7 +125,7 @@ test('compose an epic', async ({ page }) => { expect(italic).not.toBe(null); await page.type('.ql-editor', 'Moby Dick'); - expect(await quillPage.editorHTML()).toEqual( + expect(await editorPage.root.innerHTML()).toEqual( [ '

Moby Dick

', `

${CHAPTER}

`, @@ -159,7 +158,7 @@ test('compose an epic', async ({ page }) => { await page.keyboard.up(SHORTKEY); bold = await page.$('.ql-toolbar .ql-bold.ql-active'); expect(bold).not.toBe(null); - expect(await quillPage.editorHTML()).toEqual( + expect(await editorPage.root.innerHTML()).toEqual( [ '

Moby Dick

', `

${CHAPTER}

`, @@ -173,7 +172,7 @@ test('compose an epic', async ({ page }) => { await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowUp'); await page.click('.ql-toolbar .ql-header[value="1"]'); - expect(await quillPage.editorHTML()).toEqual( + expect(await editorPage.root.innerHTML()).toEqual( [ '

Moby Dick

', `

${CHAPTER}

`, @@ -198,7 +197,7 @@ test('compose an epic', async ({ page }) => { await page.keyboard.press('b'); await page.keyboard.up(SHORTKEY); await page.type('.ql-editor', 'B'); - expect(await quillPage.root.locator('p').nth(2).innerHTML()).toBe('ABA'); + expect(await editorPage.root.locator('p').nth(2).innerHTML()).toBe('ABA'); await page.keyboard.down(SHORTKEY); await page.keyboard.press('b'); await page.keyboard.up(SHORTKEY); @@ -207,7 +206,7 @@ test('compose an epic', async ({ page }) => { await page.keyboard.press('b'); await page.keyboard.up(SHORTKEY); await page.type('.ql-editor', 'D'); - expect(await quillPage.root.locator('p').nth(2).innerHTML()).toBe( + expect(await editorPage.root.locator('p').nth(2).innerHTML()).toBe( 'ABCDA', ); const selection = await page.evaluate(getSelectionInTextNode); diff --git a/e2e/pageobjects/EditorPage.ts b/e2e/pageobjects/EditorPage.ts new file mode 100644 index 0000000000..7770463e80 --- /dev/null +++ b/e2e/pageobjects/EditorPage.ts @@ -0,0 +1,182 @@ +import type { Page } from '@playwright/test'; + +interface Op { + insert?: string | Record; + delete?: number; + retain?: number | Record; + attributes?: Record; +} + +const getTextNodeDef = [ + 'el', + 'match', + ` + const walk = el.ownerDocument.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + false, + ); + if (!match) { + return walk.nextNode(); + } + + let node; + while ((node = walk.nextNode())) { + if (node.wholeText.includes(match)) { + return node; + } + } + return null; +`, +]; + +// Return after a selection change event is triggered. The purpose is +// to simulate the actions of a real user, because in reality, +// users would not perform other actions before the selection event is triggered. +const updateSelectionDef = [ + 'range', + ` + return new Promise((resolve) => { + document.addEventListener('selectionchange', () => { + setTimeout(() => { + resolve() + }, 1); // wait for Quill to update the internal selection + }, { + once: true, + }); + + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }); +`, +]; + +export default class EditorPage { + constructor(protected readonly page: Page) {} + + get root() { + return this.page.locator('.ql-editor'); + } + + async open() { + await this.page.goto('http://localhost:9000/standalone/full'); + await this.page.waitForSelector('.ql-editor', { timeout: 10000 }); + } + + async html(content: string, title = '') { + await this.page.evaluate(html => { + // @ts-expect-error + const quill = window.Quill.find( + document.querySelector('.ql-editor')?.parentElement, + ); + const contents = quill.clipboard.convert({ html, text: '\n' }); + return quill.setContents(contents); + }, `

${title}

${content}`); + } + + getSelection() { + return this.page.evaluate(() => { + // @ts-expect-error + const quill = window.Quill.find( + document.querySelector('.ql-editor')?.parentElement, + ); + return quill.getSelection(); + }); + } + + async setContents(delta: Op[]) { + await this.page.evaluate(delta => { + // @ts-expect-error + const quill = window.Quill.find( + document.querySelector('.ql-editor')?.parentElement, + ); + quill.setContents(delta); + }, delta); + } + + getContents(): Promise { + return this.page.evaluate(() => { + // @ts-expect-error + const quill = window.Quill.find( + document.querySelector('.ql-editor')?.parentElement, + ); + return quill.getContents().ops; + }); + } + + /** + * Move the cursor + * @param {string} query text of the destination with `_` indicate the cursor place. + */ + async moveCursorTo(query: string) { + const text = query.replace('_', ''); + await this.waitForText(text); + await this.page.evaluate( + async ({ getTextNodeDef, updateSelectionDef, query, text }) => { + const getTextNode = new Function(...getTextNodeDef); + const updateSelection = new Function(...updateSelectionDef); + + const editor = window.document.querySelector('.ql-editor'); + const node = getTextNode(editor, text) as Text; + if (!node) return; + const offset = node.wholeText.indexOf(text) + query.indexOf('_'); + + const document = node.ownerDocument; + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset); + await updateSelection(range); + }, + { getTextNodeDef, updateSelectionDef, query, text }, + ); + } + + moveCursorAfterText(text: string) { + return this.moveCursorTo(`${text}_`); + } + + moveCursorBeforeText(text: string) { + return this.moveCursorTo(`_${text}`); + } + + async selectText(start: string, end?: string) { + await this.waitForText(start); + if (end) { + await this.waitForText(end); + } + await this.page.evaluate( + async ({ getTextNodeDef, updateSelectionDef, start, end }) => { + const getTextNode = new Function(...getTextNodeDef); + const updateSelection = new Function(...updateSelectionDef); + + const editor = window.document.querySelector('.ql-editor'); + const anchorNode = getTextNode(editor, start) as Text; + const focusNode = end ? (getTextNode(editor, end) as Text) : anchorNode; + const anchorOffset = anchorNode.wholeText.indexOf(start); + const focusOffset = end + ? focusNode.wholeText.indexOf(end) + end.length + : anchorOffset + start.length; + + const document = anchorNode.ownerDocument; + const range = document.createRange(); + range.setStart(anchorNode, anchorOffset); + range.setEnd(focusNode, focusOffset); + await updateSelection(range); + }, + { getTextNodeDef, updateSelectionDef, start, end }, + ); + } + + private async waitForText(text: string) { + await this.page.waitForFunction( + ({ getTextNodeDef, text }) => { + const getTextNode = new Function(...getTextNodeDef); + const editor = window.document.querySelector('.ql-editor'); + return getTextNode(editor, text); + }, + { getTextNodeDef, text }, + ); + } +} diff --git a/e2e/replaceSelection.spec.ts b/e2e/replaceSelection.spec.ts new file mode 100644 index 0000000000..ef34cf0c3c --- /dev/null +++ b/e2e/replaceSelection.spec.ts @@ -0,0 +1,75 @@ +import { expect } from '@playwright/test'; +import { test } from './fixtures'; + +test.describe('replace selection', () => { + test.beforeEach(async ({ editorPage }) => { + await editorPage.open(); + }); + + test.describe('replace a colored text', () => { + test('after a normal text', async ({ page, editorPage }) => { + await editorPage.setContents([ + { insert: '1' }, + { insert: '2', attributes: { color: 'red' } }, + { insert: '3\n' }, + ]); + await editorPage.selectText('2', '3'); + await page.keyboard.type('a'); + expect(await editorPage.root.innerHTML()).toEqual( + '

1a

', + ); + expect(await editorPage.getContents()).toEqual([ + { insert: '1' }, + { insert: 'a', attributes: { color: 'red' } }, + { insert: '\n' }, + ]); + }); + + test('with Enter key', async ({ page, editorPage }) => { + await editorPage.setContents([ + { insert: '1' }, + { insert: '2', attributes: { color: 'red' } }, + { insert: '3\n' }, + ]); + await editorPage.selectText('2', '3'); + await page.keyboard.press('Enter'); + expect(await editorPage.root.innerHTML()).toEqual('

1


'); + expect(await editorPage.getContents()).toEqual([{ insert: '1\n\n' }]); + }); + + test('after a bold text', async ({ page, editorPage }) => { + await editorPage.setContents([ + { insert: '1', attributes: { bold: true } }, + { insert: '2', attributes: { color: 'red' } }, + { insert: '3\n' }, + ]); + await editorPage.selectText('2', '3'); + await page.keyboard.type('a'); + expect(await editorPage.root.innerHTML()).toEqual( + '

1a

', + ); + expect(await editorPage.getContents()).toEqual([ + { insert: '1', attributes: { bold: true } }, + { insert: 'a', attributes: { color: 'red' } }, + { insert: '\n' }, + ]); + }); + + test('across lines', async ({ page, editorPage }) => { + await editorPage.setContents([ + { insert: 'header', attributes: { color: 'red' } }, + { insert: '\n', attributes: { header: 1 } }, + { insert: 'text\n' }, + ]); + await editorPage.selectText('header', 'text'); + await page.keyboard.type('a'); + expect(await editorPage.root.innerHTML()).toEqual( + '

a

', + ); + expect(await editorPage.getContents()).toEqual([ + { insert: 'a', attributes: { color: 'red' } }, + { insert: '\n', attributes: { header: 1 } }, + ]); + }); + }); +}); diff --git a/e2e/utils/QuillPage.ts b/e2e/utils/QuillPage.ts deleted file mode 100644 index ed1b809372..0000000000 --- a/e2e/utils/QuillPage.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Page } from '@playwright/test'; - -class QuillPage { - constructor(private page: Page) {} - - get root() { - return this.page.locator('.ql-editor'); - } - - editorHTML() { - return this.root.innerHTML(); - } -} - -export default QuillPage; diff --git a/modules/input.ts b/modules/input.ts new file mode 100644 index 0000000000..e350b1be20 --- /dev/null +++ b/modules/input.ts @@ -0,0 +1,103 @@ +import Delta from 'quill-delta'; +import Module from '../core/module'; +import Quill from '../core/quill'; +import type { Range } from '../core/selection'; +import { deleteRange } from './keyboard'; + +const INSERT_TYPES = ['insertText', 'insertReplacementText']; + +class Input extends Module { + constructor(quill: Quill, options: Record) { + super(quill, options); + + quill.root.addEventListener('beforeinput', event => { + this.handleBeforeInput(event); + }); + + // Gboard with English input on Android triggers `compositionstart` sometimes even + // users are not going to type anything. + if (!/Android/i.test(navigator.userAgent)) { + quill.on(Quill.events.COMPOSITION_BEFORE_START, () => { + this.handleCompositionStart(); + }); + } + } + + private deleteRange(range: Range) { + deleteRange({ range, quill: this.quill }); + } + + private replaceText(range: Range, text = '') { + if (range.length === 0) return false; + + if (text) { + // Follow the native behavior that inherits the formats of the first character + const formats = this.quill.getFormat(range.index, 1); + this.deleteRange(range); + this.quill.updateContents( + new Delta().retain(range.index).insert(text, formats), + Quill.sources.USER, + ); + } else { + this.deleteRange(range); + } + + this.quill.setSelection(range.index + text.length, 0, Quill.sources.SILENT); + return true; + } + + private handleBeforeInput(event: InputEvent) { + if ( + this.quill.composition.isComposing || + event.defaultPrevented || + !INSERT_TYPES.includes(event.inputType) + ) { + return; + } + + const staticRange = event.getTargetRanges + ? event.getTargetRanges()[0] + : null; + if (!staticRange || staticRange.collapsed === true) { + return; + } + + const text = getPlainTextFromInputEvent(event); + if (text == null) { + return; + } + const normalized = this.quill.selection.normalizeNative(staticRange); + const range = normalized + ? this.quill.selection.normalizedToRange(normalized) + : null; + if (range && this.replaceText(range, text)) { + event.preventDefault(); + } + } + + private handleCompositionStart() { + const range = this.quill.getSelection(); + if (range) { + this.replaceText(range); + } + } +} + +function getPlainTextFromInputEvent(event: InputEvent) { + // When `inputType` is "insertText": + // - `event.data` should be string (Safari uses `event.dataTransfer`). + // - `event.dataTransfer` should be null. + // When `inputType` is "insertReplacementText": + // - `event.data` should be null. + // - `event.dataTransfer` should contain "text/plain" data. + + if (typeof event.data === 'string') { + return event.data; + } + if (event.dataTransfer?.types.includes('text/plain')) { + return event.dataTransfer.getData('text/plain'); + } + return null; +} + +export default Input;