Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Normalize UI node selection #3898

Merged
merged 3 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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