Skip to content

Commit

Permalink
Normalize UI node selection
Browse files Browse the repository at this point in the history
  • Loading branch information
luin committed Nov 2, 2023
1 parent bc35035 commit 165811b
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 21 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 UINodeSelection from './modules/uiNodeSelection';

export { Delta, Op, OpIterator, AttributeMap };

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

export default Quill;
78 changes: 67 additions & 11 deletions e2e/list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
]);
});
});
}
});
88 changes: 88 additions & 0 deletions modules/uiNodeSelection.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>) {
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;
1 change: 1 addition & 0 deletions website/src/components/standalone/FullEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const FullEditor = () => (
modules: {
syntax: true,
toolbar: '#toolbar-container',
uiNodeSelection: true,
},
placeholder: 'Compose an epic...',
theme: 'snow',
Expand Down

0 comments on commit 165811b

Please sign in to comment.