Skip to content

Commit

Permalink
Record selection for history undo/redo
Browse files Browse the repository at this point in the history
  • Loading branch information
luin committed Jul 22, 2023
1 parent 84437aa commit 453743a
Show file tree
Hide file tree
Showing 3 changed files with 218 additions and 25 deletions.
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
93 changes: 68 additions & 25 deletions modules/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,34 @@ 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) => {
Expand All @@ -36,6 +42,19 @@ class History extends Module<HistoryOptions> {
}
},
);

this.quill.on(Quill.events.EDITOR_CHANGE, (...args) => {
if (args[0] === Quill.events.SELECTION_CHANGE) {
const range = args[1];
if (range && args[3] !== Quill.sources.SILENT) {
this.currentRange = range;
}
} else if (args[0] === Quill.events.TEXT_CHANGE) {
const [, change] = args;
this.currentRange = transformRange(this.currentRange, change);
}
});

this.quill.keyboard.addBinding(
{ key: 'z', shortKey: true },
this.undo.bind(this),
Expand Down Expand Up @@ -64,17 +83,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 +111,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 +146,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 +202,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 };

0 comments on commit 453743a

Please sign in to comment.