Skip to content

Commit

Permalink
Merge pull request #3898 from quilljs/zh-ui-selection
Browse files Browse the repository at this point in the history
Normalize UI node selection
  • Loading branch information
luin authored Nov 15, 2023
2 parents bc35035 + dc1ec26 commit 69134e4
Show file tree
Hide file tree
Showing 10 changed files with 408 additions and 18 deletions.
11 changes: 1 addition & 10 deletions assets/core.styl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -34,6 +35,7 @@ Quill.register({
'modules/keyboard': Keyboard,
'modules/uploader': Uploader,
'modules/input': Input,
'modules/uiNode': UINode,
});

export default Quill;
1 change: 1 addition & 0 deletions core/quill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
108 changes: 108 additions & 0 deletions e2e/fixtures/Composition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type {
CDPSession,
Page,
PlaywrightWorkerArgs,
PlaywrightWorkerOptions,
} from '@playwright/test';

abstract class CompositionSession {
abstract update(key: string): Promise<void>;
abstract commit(committedText: string): Promise<void>;

protected composingData = '';

constructor(protected page: Page) {}

protected async withKeyboardEvents(
key: string,
callback: () => Promise<void>,
) {
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;
10 changes: 10 additions & 0 deletions e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -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.';
Expand Down
143 changes: 135 additions & 8 deletions e2e/list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
]);
});
});
21 changes: 21 additions & 0 deletions e2e/pageobjects/EditorPage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Page } from '@playwright/test';
import type Composition from '../fixtures/Composition';

interface Op {
insert?: string | Record<string, unknown>;
Expand Down Expand Up @@ -81,6 +82,26 @@ export default class EditorPage {
});
}

async setSelection(index: number, length: number): Promise<void>;
async setSelection(range: { index: number; length: number }): Promise<void>;
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
Expand Down
12 changes: 12 additions & 0 deletions e2e/replaceSelection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<p>1我</p>');
expect(await editorPage.getContents()).toEqual([{ insert: '1我\n' }]);
});

test('after a bold text', async ({ page, editorPage }) => {
await editorPage.setContents([
{ insert: '1', attributes: { bold: true } },
Expand Down
Loading

0 comments on commit 69134e4

Please sign in to comment.