From 165811b50f0ab16969314091dfbbc67c26c582d9 Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Sun, 1 Oct 2023 08:42:41 +0800 Subject: [PATCH] Normalize UI node selection --- assets/core.styl | 11 +-- core.ts | 2 + e2e/list.spec.ts | 78 +++++++++++++--- modules/uiNodeSelection.ts | 88 +++++++++++++++++++ .../src/components/standalone/FullEditor.jsx | 1 + 5 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 modules/uiNodeSelection.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..988d0c5561 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 UINodeSelection from './modules/uiNodeSelection'; export { Delta, Op, OpIterator, AttributeMap }; @@ -34,6 +35,7 @@ Quill.register({ 'modules/keyboard': Keyboard, 'modules/uploader': Uploader, 'modules/input': Input, + 'modules/uiSelection': UINodeSelection, }); export default Quill; 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/uiNodeSelection.ts b/modules/uiNodeSelection.ts new file mode 100644 index 0000000000..08ba7c1096 --- /dev/null +++ b/modules/uiNodeSelection.ts @@ -0,0 +1,88 @@ +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 UINodeSelection extends Module { + isListening = false; + selectionChangeDeadline = 0; + + constructor(quill: Quill, options: Record) { + super(quill, options); + + this.quill.keyboard.addBinding({ + key: 'ArrowLeft', + shiftKey: null, + handler(range, { line, offset, event }) { + if (offset === 0 && line instanceof ParentBlot && line.uiNode) { + quill.setSelection( + range.index - 1, + range.length + (event.shiftKey ? 1 : 0), + Quill.sources.USER, + ); + return false; + } + return true; + }, + }); + this.startMonitoringSelectionChange(); + } + + private startMonitoringSelectionChange() { + 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; + document.addEventListener('selectionchange', this.handleSelectionChange, { + once: true, + }); + } + + private handleSelectionChange = () => { + this.isListening = false; + if (Date.now() > this.selectionChangeDeadline) return; + + 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 UINodeSelection; diff --git a/website/src/components/standalone/FullEditor.jsx b/website/src/components/standalone/FullEditor.jsx index ec4842f21f..c863ed5133 100644 --- a/website/src/components/standalone/FullEditor.jsx +++ b/website/src/components/standalone/FullEditor.jsx @@ -53,6 +53,7 @@ const FullEditor = () => ( modules: { syntax: true, toolbar: '#toolbar-container', + uiNodeSelection: true, }, placeholder: 'Compose an epic...', theme: 'snow',