Skip to content

Commit

Permalink
Add RTL support
Browse files Browse the repository at this point in the history
  • Loading branch information
luin committed Nov 5, 2023
1 parent 0ea789f commit dc1ec26
Show file tree
Hide file tree
Showing 2 changed files with 140 additions and 75 deletions.
175 changes: 112 additions & 63 deletions e2e/list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
]);
});
});
40 changes: 28 additions & 12 deletions modules/uiNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
// <ANY_PARENT>[CARET]<div class="ql-ui"></div>[CONTENT]</ANY_PARENT>
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'
Expand Down Expand Up @@ -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;
},
});
}
Expand All @@ -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;

Expand Down

0 comments on commit dc1ec26

Please sign in to comment.