From dc303a9d607e35b2e64c7001e8c0cce62c92f1ee Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Sun, 1 Oct 2023 08:42:41 +0800 Subject: [PATCH 1/3] Add new module to handle ui node interactions --- assets/core.styl | 11 +--- core.ts | 2 + core/quill.ts | 1 + e2e/list.spec.ts | 78 ++++++++++++++++++++---- modules/uiNode.ts | 98 +++++++++++++++++++++++++++++++ test/unit/modules/toolbar.spec.ts | 2 + 6 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 modules/uiNode.ts 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/list.spec.ts b/e2e/list.spec.ts index a791f5ba6c..fe41e9c14a 100644 --- a/e2e/list.spec.ts +++ b/e2e/list.spec.ts @@ -2,22 +2,78 @@ import { expect } from '@playwright/test'; import { test } from './fixtures'; import { isMac } from './utils'; +const listTypes = ['bullet', 'ordered', 'checked']; + test.describe('list', () => { test.beforeEach(async ({ editorPage }) => { await editorPage.open(); }); - test('navigating with shortcuts', async ({ page, editorPage }) => { - await editorPage.setContents([ - { insert: 'item 1' }, - { insert: '\n', attributes: { list: 'bullet' } }, - ]); + for (const list of listTypes) { + test(`jump to line start (${list})`, 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 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 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 (${list})`, 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('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + expect(await editorPage.getSelection()).toEqual({ + index: 'first line'.length, + length: 0, + }); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + expect(await editorPage.getSelection()).toEqual({ + index: 'first line\ns'.length, + length: 0, + }); + }); + + test(`extend selection to previous/next line (${list})`, 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 } }, + ]); + }); + }); + } }); diff --git a/modules/uiNode.ts b/modules/uiNode.ts new file mode 100644 index 0000000000..9020895323 --- /dev/null +++ b/modules/uiNode.ts @@ -0,0 +1,98 @@ +import { ParentBlot } from 'parchment'; +import Module from '../core/module'; +import Quill from '../core/quill'; + +const isMac = /Mac/i.test(navigator.platform); + +const canMoveCaretBeforeUINode = (event: KeyboardEvent) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + 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', + shiftKey: null, + handler(range, { line, offset, event }) { + if (offset === 0 && line instanceof ParentBlot && line.uiNode) { + this.quill.setSelection( + range.index - 1, + range.length + (event.shiftKey ? 1 : 0), + Quill.sources.USER, + ); + return false; + } + return true; + }, + }); + } + + private handleNavigationShortcuts() { + this.quill.root.addEventListener('keydown', (event) => { + if (!event.defaultPrevented && canMoveCaretBeforeUINode(event)) { + this.ensureListeningToSelectionChange(); + } + }); + } + + 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, ); From 0ea789f95fc4956287b3995f9495aafa367d4190 Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Fri, 3 Nov 2023 15:07:23 +0800 Subject: [PATCH 2/3] Add tests for IME --- e2e/fixtures/Composition.ts | 108 ++++++++++++++++++++++++++++++++++ e2e/fixtures/index.ts | 10 ++++ e2e/list.spec.ts | 24 +++++++- e2e/pageobjects/EditorPage.ts | 21 +++++++ e2e/replaceSelection.spec.ts | 12 ++++ modules/uiNode.ts | 4 +- 6 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 e2e/fixtures/Composition.ts 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 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 } }, diff --git a/modules/uiNode.ts b/modules/uiNode.ts index 9020895323..7ea9586ffa 100644 --- a/modules/uiNode.ts +++ b/modules/uiNode.ts @@ -4,10 +4,12 @@ import Quill from '../core/quill'; const isMac = /Mac/i.test(navigator.platform); +// A loose check to see if the shortcut can move the caret before a UI node: +// [CARET]
[CONTENT]
const canMoveCaretBeforeUINode = (event: KeyboardEvent) => { if ( event.key === 'ArrowLeft' || - event.key === 'ArrowRight' || + event.key === 'ArrowRight' || // RTL language or moving from the end of the previous line event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Home' From dc1ec26628e91c77098e5bbaa5f9d78d594ed31f Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Sun, 5 Nov 2023 11:00:15 +0800 Subject: [PATCH 3/3] Add RTL support --- e2e/list.spec.ts | 175 +++++++++++++++++++++++++++++----------------- modules/uiNode.ts | 40 +++++++---- 2 files changed, 140 insertions(+), 75 deletions(-) diff --git a/e2e/list.spec.ts b/e2e/list.spec.ts index d1c8c3487a..687c047dcf 100644 --- a/e2e/list.spec.ts +++ b/e2e/list.spec.ts @@ -10,92 +10,141 @@ test.describe('list', () => { }); for (const list of listTypes) { - test(`jump to line start (${list})`, 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 (${list})`, async ({ - page, - editorPage, - }) => { + test.describe(`navigation with shortcuts ${list}`, () => { + test('jump to line start', async ({ page, editorPage }) => { await editorPage.setContents([ - { insert: 'first line' }, - { insert: '\n', attributes: { list } }, - { insert: 'second line' }, + { insert: 'item 1' }, { insert: '\n', attributes: { list } }, ]); - await editorPage.moveCursorTo('s_econd'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); + await editorPage.moveCursorAfterText('item 1'); + await page.keyboard.press(isMac ? `Meta+ArrowLeft` : 'Home'); expect(await editorPage.getSelection()).toEqual({ - index: 'first line'.length, + index: 0, length: 0, }); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('ArrowRight'); - expect(await editorPage.getSelection()).toEqual({ - index: 'first line\ns'.length, - 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 } }, + ]); }); }); - test(`extend selection to previous/next line (${list})`, async ({ - page, + // https://github.com/quilljs/quill/issues/3837 + test('typing at beginning with IME', async ({ editorPage, + composition, }) => { await editorPage.setContents([ - { insert: 'first line' }, + { insert: 'item 1' }, { insert: '\n', attributes: { list } }, - { insert: 'second line' }, + { insert: '' }, { 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'); + await editorPage.setSelection(7, 0); + await editorPage.typeWordWithIME(composition, '我'); expect(await editorPage.getContents()).toEqual([ - { insert: 'first lineaecond line' }, + { insert: 'item 1' }, + { insert: '\n', attributes: { list } }, + { insert: '我' }, { insert: '\n', attributes: { 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 } }, - ]); + test('checklist is checkable', async ({ editorPage, page }) => { + await editorPage.setContents([ + { insert: 'item 1' }, + { insert: '\n', attributes: { list: 'unchecked' } }, + ]); - 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 } }, - ]); + 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/modules/uiNode.ts b/modules/uiNode.ts index 7ea9586ffa..488eb2cb2e 100644 --- a/modules/uiNode.ts +++ b/modules/uiNode.ts @@ -4,12 +4,12 @@ import Quill from '../core/quill'; const isMac = /Mac/i.test(navigator.platform); -// A loose check to see if the shortcut can move the caret before a UI node: +// 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 language or moving from the end of the previous line + event.key === 'ArrowRight' || // RTL scripts or moving from the end of the previous line event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Home' @@ -37,18 +37,28 @@ class UINode extends Module { private handleArrowKeys() { this.quill.keyboard.addBinding({ - key: 'ArrowLeft', + key: ['ArrowLeft', 'ArrowRight'], + offset: 0, shiftKey: null, - handler(range, { line, offset, event }) { - if (offset === 0 && line instanceof ParentBlot && line.uiNode) { - this.quill.setSelection( - range.index - 1, - range.length + (event.shiftKey ? 1 : 0), - Quill.sources.USER, - ); - return false; + handler(range, { line, event }) { + if (!(line instanceof ParentBlot) || !line.uiNode) { + return true; } - 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; }, }); } @@ -61,6 +71,12 @@ class UINode extends Module { }); } + /** + * 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;