diff --git a/assets/core.styl b/assets/core.styl index b16de126e7..b4645ca6fc 100644 --- a/assets/core.styl +++ b/assets/core.styl @@ -71,6 +71,7 @@ resets(arr) li list-style-type: none padding-left: LIST_STYLE_OUTER_WIDTH + position: relative > .ql-ui:before display: inline-block @@ -80,12 +81,6 @@ resets(arr) white-space: nowrap width: LIST_STYLE_WIDTH - @supports (display: contents) - li[data-list=bullet], - li[data-list=ordered] - > .ql-ui - display: contents - li[data-list=checked], li[data-list=unchecked] > .ql-ui @@ -210,10 +205,6 @@ resets(arr) .ql-ui position: absolute - li - > .ql-ui - position: static; - .ql-editor.ql-blank::before color: rgba(0,0,0,0.6) content: attr(data-placeholder) diff --git a/core.ts b/core.ts index 95009c43ff..545ef2f552 100644 --- a/core.ts +++ b/core.ts @@ -15,6 +15,7 @@ import Keyboard from './modules/keyboard'; import Uploader from './modules/uploader'; import Delta, { Op, OpIterator, AttributeMap } from 'quill-delta'; import Input from './modules/input'; +import UINode from './modules/uiNode'; export { Delta, Op, OpIterator, AttributeMap }; @@ -34,6 +35,7 @@ Quill.register({ 'modules/keyboard': Keyboard, 'modules/uploader': Uploader, 'modules/input': Input, + 'modules/uiNode': UINode, }); export default Quill; diff --git a/core/quill.ts b/core/quill.ts index 089cfd8135..ae53eec3c5 100644 --- a/core/quill.ts +++ b/core/quill.ts @@ -186,6 +186,7 @@ class Quill { this.history = this.theme.addModule('history'); this.uploader = this.theme.addModule('uploader'); this.theme.addModule('input'); + this.theme.addModule('uiNode'); this.theme.init(); this.emitter.on(Emitter.events.EDITOR_CHANGE, (type) => { if (type === Emitter.events.TEXT_CHANGE) { diff --git a/e2e/fixtures/Composition.ts b/e2e/fixtures/Composition.ts new file mode 100644 index 0000000000..38628e12cc --- /dev/null +++ b/e2e/fixtures/Composition.ts @@ -0,0 +1,108 @@ +import type { + CDPSession, + Page, + 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); + }); + } +} + +class Composition { + constructor( + private page: Page, + private browserName: PlaywrightWorkerOptions['browserName'], + private playwright: PlaywrightWorkerArgs['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 a791f5ba6c..687c047dcf 100644 --- a/e2e/list.spec.ts +++ b/e2e/list.spec.ts @@ -2,22 +2,149 @@ import { expect } from '@playwright/test'; import { test } from './fixtures'; import { isMac } from './utils'; +const listTypes = ['bullet', 'checked']; + test.describe('list', () => { test.beforeEach(async ({ editorPage }) => { await editorPage.open(); }); - test('navigating with shortcuts', async ({ page, editorPage }) => { + for (const list of listTypes) { + test.describe(`navigation with shortcuts ${list}`, () => { + test('jump to line start', async ({ page, editorPage }) => { + await editorPage.setContents([ + { insert: 'item 1' }, + { insert: '\n', attributes: { list } }, + ]); + + await editorPage.moveCursorAfterText('item 1'); + await page.keyboard.press(isMac ? `Meta+ArrowLeft` : 'Home'); + expect(await editorPage.getSelection()).toEqual({ + index: 0, + length: 0, + }); + + await page.keyboard.type('start '); + expect(await editorPage.getContents()).toEqual([ + { insert: 'start item 1' }, + { insert: '\n', attributes: { list } }, + ]); + }); + + test.describe('navigation with left/right arrow keys', () => { + test('move to previous/next line', async ({ page, editorPage }) => { + const firstLine = 'first line'; + await editorPage.setContents([ + { insert: firstLine }, + { insert: '\n', attributes: { list } }, + { insert: 'second line' }, + { insert: '\n', attributes: { list } }, + ]); + + await editorPage.setSelection(firstLine.length + 2, 0); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + expect(await editorPage.getSelection()).toEqual({ + index: firstLine.length, + length: 0, + }); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + expect(await editorPage.getSelection()).toEqual({ + index: firstLine.length + 2, + length: 0, + }); + }); + + test('RTL support', async ({ page, editorPage }) => { + const firstLine = 'اللغة العربية'; + await editorPage.setContents([ + { insert: firstLine }, + { insert: '\n', attributes: { list, direction: 'rtl' } }, + { insert: 'توحيد اللهجات العربية' }, + { insert: '\n', attributes: { list, direction: 'rtl' } }, + ]); + + await editorPage.setSelection(firstLine.length + 2, 0); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + expect(await editorPage.getSelection()).toEqual({ + index: firstLine.length, + length: 0, + }); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + expect(await editorPage.getSelection()).toEqual({ + index: firstLine.length + 2, + length: 0, + }); + }); + + test('extend selection to previous/next line', async ({ + page, + editorPage, + }) => { + await editorPage.setContents([ + { insert: 'first line' }, + { insert: '\n', attributes: { list } }, + { insert: 'second line' }, + { insert: '\n', attributes: { list } }, + ]); + + await editorPage.moveCursorTo('s_econd'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.type('a'); + expect(await editorPage.getContents()).toEqual([ + { insert: 'first lineaecond line' }, + { insert: '\n', attributes: { list } }, + ]); + }); + }); + + // https://github.com/quilljs/quill/issues/3837 + test('typing at beginning with IME', 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 } }, + ]); + }); + }); + } + + test('checklist is checkable', async ({ editorPage, page }) => { await editorPage.setContents([ { insert: 'item 1' }, - { insert: '\n', attributes: { list: 'bullet' } }, + { insert: '\n', attributes: { list: 'unchecked' } }, ]); - await editorPage.moveCursorAfterText('item 1'); - await page.keyboard.press(isMac ? `Meta+ArrowLeft` : 'Home'); - expect(await editorPage.getSelection()).toEqual({ index: 0, length: 0 }); - - await page.keyboard.press(isMac ? `Meta+ArrowRight` : 'End'); - expect(await editorPage.getSelection()).toEqual({ index: 6, length: 0 }); + await editorPage.setSelection(7, 0); + const rect = await editorPage.root.locator('li').evaluate((element) => { + return element.getBoundingClientRect(); + }); + await page.mouse.click(rect.left + 5, rect.top + 5); + expect(await editorPage.getContents()).toEqual([ + { insert: 'item 1' }, + { insert: '\n', attributes: { list: 'checked' } }, + ]); + await page.mouse.click(rect.left + 5, rect.top + 5); + expect(await editorPage.getContents()).toEqual([ + { insert: 'item 1' }, + { insert: '\n', attributes: { list: 'unchecked' } }, + ]); }); }); 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 } }, diff --git a/modules/uiNode.ts b/modules/uiNode.ts new file mode 100644 index 0000000000..488eb2cb2e --- /dev/null +++ b/modules/uiNode.ts @@ -0,0 +1,116 @@ +import { ParentBlot } from 'parchment'; +import Module from '../core/module'; +import Quill from '../core/quill'; + +const isMac = /Mac/i.test(navigator.platform); + +// A loose check to determine if the shortcut can move the caret before a UI node: +// [CARET]
[CONTENT]
+const canMoveCaretBeforeUINode = (event: KeyboardEvent) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || // RTL scripts or moving from the end of the previous line + event.key === 'ArrowUp' || + event.key === 'ArrowDown' || + event.key === 'Home' + ) { + return true; + } + + if (isMac && event.key === 'a' && event.ctrlKey === true) { + return true; + } + + return false; +}; + +class UINode extends Module { + isListening = false; + selectionChangeDeadline = 0; + + constructor(quill: Quill, options: Record) { + super(quill, options); + + this.handleArrowKeys(); + this.handleNavigationShortcuts(); + } + + private handleArrowKeys() { + this.quill.keyboard.addBinding({ + key: ['ArrowLeft', 'ArrowRight'], + offset: 0, + shiftKey: null, + handler(range, { line, event }) { + if (!(line instanceof ParentBlot) || !line.uiNode) { + return true; + } + + const isRTL = getComputedStyle(line.domNode)['direction'] === 'rtl'; + if ( + (isRTL && event.key !== 'ArrowRight') || + (!isRTL && event.key !== 'ArrowLeft') + ) { + return true; + } + + this.quill.setSelection( + range.index - 1, + range.length + (event.shiftKey ? 1 : 0), + Quill.sources.USER, + ); + return false; + }, + }); + } + + private handleNavigationShortcuts() { + this.quill.root.addEventListener('keydown', (event) => { + if (!event.defaultPrevented && canMoveCaretBeforeUINode(event)) { + this.ensureListeningToSelectionChange(); + } + }); + } + + /** + * We only listen to the `selectionchange` event when + * there is an intention of moving the caret to the beginning using shortcuts. + * This is primarily implemented to prevent infinite loops, as we are changing + * the selection within the handler of a `selectionchange` event. + */ + private ensureListeningToSelectionChange() { + if (this.isListening) return; + + this.isListening = true; + this.selectionChangeDeadline = Date.now() + 100; + + const listener = () => { + this.isListening = false; + + if (Date.now() <= this.selectionChangeDeadline) { + this.handleSelectionChange(); + } + }; + + document.addEventListener('selectionchange', listener, { + once: true, + }); + } + + private handleSelectionChange() { + const selection = document.getSelection(); + if (!selection) return; + const range = selection.getRangeAt(0); + if (range.collapsed !== true || range.startOffset !== 0) return; + + const line = this.quill.scroll.find(range.startContainer); + if (!(line instanceof ParentBlot) || !line.uiNode) return; + + const newRange = document.createRange(); + newRange.setStartAfter(line.uiNode); + newRange.setEndAfter(line.uiNode); + selection.removeAllRanges(); + selection.addRange(newRange); + } +} + +export default UINode; diff --git a/test/unit/modules/toolbar.spec.ts b/test/unit/modules/toolbar.spec.ts index a374588ebc..1b491d9f97 100644 --- a/test/unit/modules/toolbar.spec.ts +++ b/test/unit/modules/toolbar.spec.ts @@ -14,6 +14,7 @@ import { SizeClass } from '../../../formats/size'; import Bold from '../../../formats/bold'; import Link from '../../../formats/link'; import { AlignClass } from '../../../formats/align'; +import UINode from '../../../modules/uiNode'; const createContainer = (html = '') => { const container = document.body.appendChild(document.createElement('div')); @@ -152,6 +153,7 @@ describe('Toolbar', () => { 'modules/history': History, 'modules/uploader': Uploader, 'modules/input': Input, + 'modules/uiNode': UINode, }, true, );