From 466f5a0ee9eaa4af46bbec7a6e047eb5d0ac907a Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Mon, 3 Jul 2023 18:28:15 +0800 Subject: [PATCH 1/2] Record selection for history undo/redo --- e2e/history.spec.ts | 133 ++++++++++++++++++++++++++++++++++ e2e/pageobjects/EditorPage.ts | 17 +++++ modules/history.ts | 93 +++++++++++++++++------- 3 files changed, 218 insertions(+), 25 deletions(-) create mode 100644 e2e/history.spec.ts diff --git a/e2e/history.spec.ts b/e2e/history.spec.ts new file mode 100644 index 0000000000..d568773718 --- /dev/null +++ b/e2e/history.spec.ts @@ -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 }); + }); + }); +}); diff --git a/e2e/pageobjects/EditorPage.ts b/e2e/pageobjects/EditorPage.ts index aa106f04ce..02a03d56f2 100644 --- a/e2e/pageobjects/EditorPage.ts +++ b/e2e/pageobjects/EditorPage.ts @@ -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 diff --git a/modules/history.ts b/modules/history.ts index 72a52bd030..2f530dc0ba 100644 --- a/modules/history.ts +++ b/modules/history.ts @@ -3,6 +3,7 @@ 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; @@ -10,21 +11,26 @@ interface HistoryOptions { maxStack: number; } +export interface StackItem { + delta: Delta; + range: Range | null; +} + +interface Stack { + undo: StackItem[]; + redo: StackItem[]; +} + class History extends Module { 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) { super(quill, options); - this.lastRecorded = 0; - this.ignoreChange = false; - this.clear(); this.quill.on( Quill.events.EDITOR_CHANGE, (eventName, delta, oldDelta, source) => { @@ -36,6 +42,19 @@ class History extends Module { } }, ); + + 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), @@ -64,17 +83,20 @@ class History extends Module { 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() { @@ -89,21 +111,23 @@ class History extends Module { 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(); @@ -122,6 +146,15 @@ class History extends Module { 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, @@ -129,13 +162,16 @@ History.DEFAULTS = { 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); } } @@ -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 }; From f5ced1b59badb8c2e6c30c742fe2d6f284fbd464 Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Sat, 22 Jul 2023 14:02:35 +0800 Subject: [PATCH 2/2] Address feedbacks --- modules/history.ts | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/modules/history.ts b/modules/history.ts index 2f530dc0ba..17ff91efbb 100644 --- a/modules/history.ts +++ b/modules/history.ts @@ -33,28 +33,25 @@ class History extends Module { super(quill, options); 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.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),