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

Record selection in history module #3823

Merged
merged 2 commits into from
Jul 22, 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
133 changes: 133 additions & 0 deletions e2e/history.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Page, expect } from '@playwright/test';
import { test } from './fixtures';
import { SHORTKEY } from './utils';

const undo = (page: Page) => page.keyboard.press(`${SHORTKEY}+z`);
const redo = (page: Page) => page.keyboard.press(`${SHORTKEY}+Shift+z`);

const setUserOnly = (page: Page, value: boolean) =>
page.evaluate(
value => {
// @ts-expect-error
window.quill.history.options.userOnly = value;
},
[value],
);

test.describe('history', () => {
test.beforeEach(async ({ editorPage }) => {
await editorPage.open();
await editorPage.setContents([{ insert: '1234\n' }]);
await editorPage.cutoffHistory();
});

test('skip changes reverted by api', async ({ page, editorPage }) => {
await setUserOnly(page, true);
await editorPage.moveCursorAfterText('12');
await page.keyboard.type('a');
await editorPage.cutoffHistory();
await editorPage.selectText('34');
await page.keyboard.press(`${SHORTKEY}+b`);
await editorPage.cutoffHistory();
await editorPage.updateContents([
{ retain: 3 },
{ retain: 2, attributes: { bold: null } },
]);
await undo(page);
expect(await editorPage.getContents()).toEqual([{ insert: '1234\n' }]);
});

test.describe('selection', () => {
test('typing', async ({ page, editorPage }) => {
await editorPage.moveCursorAfterText('2');
await page.keyboard.type('a');
await editorPage.cutoffHistory();
await page.keyboard.type('b');
await editorPage.cutoffHistory();
await page.keyboard.press('Backspace');
await editorPage.cutoffHistory();
await page.keyboard.type('c');
await editorPage.cutoffHistory();
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 3, length: 0 });
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 4, length: 0 });
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 3, length: 0 });
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 2, length: 0 });
});

test('delete forward', async ({ page, editorPage }) => {
await editorPage.moveCursorAfterText('3');
await page.keyboard.press('Backspace');
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 3, length: 0 });
await redo(page);
expect(await editorPage.getSelection()).toEqual({ index: 2, length: 0 });
});

test('delete selection', async ({ page, editorPage }) => {
await editorPage.selectText('23');
await page.keyboard.press('Backspace');
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 1, length: 2 });
await redo(page);
expect(await editorPage.getSelection()).toEqual({ index: 1, length: 0 });
});

test('format selection', async ({ page, editorPage }) => {
await editorPage.selectText('23');
await page.keyboard.press(`${SHORTKEY}+b`);
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 1, length: 2 });
await redo(page);
expect(await editorPage.getSelection()).toEqual({ index: 1, length: 2 });
});

test('combine operations', async ({ page, editorPage }) => {
await editorPage.selectText('23');
await page.keyboard.type('a');
await editorPage.cutoffHistory();
await page.keyboard.type('bc');
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 2, length: 0 });
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 1, length: 2 });
await redo(page);
expect(await editorPage.getSelection()).toEqual({ index: 2, length: 0 });
await redo(page);
expect(await editorPage.getSelection()).toEqual({ index: 4, length: 0 });
});

test('api changes', async ({ page, editorPage }) => {
await setUserOnly(page, true);
await editorPage.selectText('23');
await page.keyboard.press('Backspace');
await editorPage.cutoffHistory();
await page.keyboard.type('a');
await editorPage.cutoffHistory();
await editorPage.updateContents([{ insert: '0' }]);
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 2, length: 0 });
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 2, length: 2 });
});

test('programmatic user changes', async ({ page, editorPage }) => {
await editorPage.moveCursorAfterText('12');
await page.keyboard.type('a');
await editorPage.cutoffHistory();
await editorPage.updateContents([{ insert: '0' }], 'user');
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 3, length: 0 });
});

test('no user selection', async ({ page, editorPage }) => {
await editorPage.updateContents([{ retain: 3 }, { insert: '0' }], 'user');
await editorPage.root.click();
await undo(page);
expect(await editorPage.getSelection()).toEqual({ index: 3, length: 0 });
});
});
});
17 changes: 17 additions & 0 deletions e2e/pageobjects/EditorPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ export default class EditorPage {
});
}

async cutoffHistory() {
await this.page.evaluate(() => {
// @ts-expect-error
window.quill.history.cutoff();
});
}

async updateContents(delta: Op[], source: 'api' | 'user' = 'api') {
await this.page.evaluate(
({ delta, source }) => {
// @ts-expect-error
window.quill.updateContents(delta, source);
},
{ delta, source },
);
}

async setContents(delta: Op[]) {
await this.page.evaluate(delta => {
// @ts-expect-error
Expand Down
102 changes: 71 additions & 31 deletions modules/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,55 @@ import Delta from 'quill-delta';
import Module from '../core/module';
import Quill from '../core/quill';
import type Scroll from '../blots/scroll';
import { Range } from '../core/selection';

interface HistoryOptions {
userOnly: boolean;
delay: number;
maxStack: number;
}

export interface StackItem {
delta: Delta;
range: Range | null;
}

interface Stack {
undo: StackItem[];
redo: StackItem[];
}

class History extends Module<HistoryOptions> {
static DEFAULTS: HistoryOptions;

lastRecorded: number;
ignoreChange: boolean;
stack: {
undo: Delta[];
redo: Delta[];
};
lastRecorded = 0;
ignoreChange = false;
stack: Stack = { undo: [], redo: [] };
currentRange: Range | null = null;

constructor(quill: Quill, options: Partial<HistoryOptions>) {
super(quill, options);
this.lastRecorded = 0;
this.ignoreChange = false;
this.clear();
this.quill.on(
Quill.events.EDITOR_CHANGE,
(eventName, delta, oldDelta, source) => {
if (eventName !== Quill.events.TEXT_CHANGE || this.ignoreChange) return;
if (!this.options.userOnly || source === Quill.sources.USER) {
this.record(delta, oldDelta);
} else {
this.transform(delta);
(eventName, value, oldValue, source) => {
if (eventName === Quill.events.SELECTION_CHANGE) {
if (value && source !== Quill.sources.SILENT) {
this.currentRange = value;
}
} else if (eventName === Quill.events.TEXT_CHANGE) {
if (!this.ignoreChange) {
if (!this.options.userOnly || source === Quill.sources.USER) {
this.record(value, oldValue);
} else {
this.transform(value);
}
}

this.currentRange = transformRange(this.currentRange, value);
}
},
);

this.quill.keyboard.addBinding(
{ key: 'z', shortKey: true },
this.undo.bind(this),
Expand Down Expand Up @@ -64,17 +80,20 @@ class History extends Module<HistoryOptions> {

change(source: 'undo' | 'redo', dest: 'redo' | 'undo') {
if (this.stack[source].length === 0) return;
const delta = this.stack[source].pop();
if (!delta) return;
const item = this.stack[source].pop();
if (!item) return;
const base = this.quill.getContents();
const inverseDelta = delta.invert(base);
this.stack[dest].push(inverseDelta);
const inverseDelta = item.delta.invert(base);
this.stack[dest].push({
delta: inverseDelta,
range: transformRange(item.range, inverseDelta),
});
this.lastRecorded = 0;
this.ignoreChange = true;
this.quill.updateContents(delta, Quill.sources.USER);
this.quill.updateContents(item.delta, Quill.sources.USER);
this.ignoreChange = false;
const index = getLastChangeIndex(this.quill.scroll, delta);
this.quill.setSelection(index, Quill.sources.USER);

this.restoreSelection(item);
}

clear() {
Expand All @@ -89,21 +108,23 @@ class History extends Module<HistoryOptions> {
if (changeDelta.ops.length === 0) return;
this.stack.redo = [];
let undoDelta = changeDelta.invert(oldDelta);
let undoRange = this.currentRange;
const timestamp = Date.now();
if (
// @ts-expect-error Fix me later
this.lastRecorded + this.options.delay > timestamp &&
this.stack.undo.length > 0
) {
const delta = this.stack.undo.pop();
if (delta) {
undoDelta = undoDelta.compose(delta);
const item = this.stack.undo.pop();
if (item) {
undoDelta = undoDelta.compose(item.delta);
undoRange = item.range;
}
} else {
this.lastRecorded = timestamp;
}
if (undoDelta.length() === 0) return;
this.stack.undo.push(undoDelta);
this.stack.undo.push({ delta: undoDelta, range: undoRange });
// @ts-expect-error Fix me later
if (this.stack.undo.length > this.options.maxStack) {
this.stack.undo.shift();
Expand All @@ -122,20 +143,32 @@ class History extends Module<HistoryOptions> {
undo() {
this.change('undo', 'redo');
}

protected restoreSelection(stackItem: StackItem) {
if (stackItem.range) {
this.quill.setSelection(stackItem.range, Quill.sources.USER);
} else {
const index = getLastChangeIndex(this.quill.scroll, stackItem.delta);
this.quill.setSelection(index, Quill.sources.USER);
}
}
}
History.DEFAULTS = {
delay: 1000,
maxStack: 100,
userOnly: false,
};

function transformStack(stack: Delta[], delta: Delta) {
function transformStack(stack: StackItem[], delta: Delta) {
let remoteDelta = delta;
for (let i = stack.length - 1; i >= 0; i -= 1) {
const oldDelta = stack[i];
stack[i] = remoteDelta.transform(oldDelta, true);
remoteDelta = oldDelta.transform(remoteDelta);
if (stack[i].length() === 0) {
const oldItem = stack[i];
stack[i] = {
delta: remoteDelta.transform(oldItem.delta, true),
range: oldItem.range && transformRange(oldItem.range, remoteDelta),
};
remoteDelta = oldItem.delta.transform(remoteDelta);
if (stack[i].delta.length() === 0) {
stack.splice(i, 1);
}
}
Expand Down Expand Up @@ -166,4 +199,11 @@ function getLastChangeIndex(scroll: Scroll, delta: Delta) {
return changeIndex;
}

function transformRange(range: Range | null, delta: Delta) {
if (!range) return range;
const start = delta.transformPosition(range.index);
const end = delta.transformPosition(range.index + range.length);
return { index: start, length: end - start };
}

export { History as default, getLastChangeIndex };