From 03458dc641169558a2e09895205389dfa2c37793 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Sun, 23 Jun 2019 13:04:14 +0200 Subject: [PATCH] Port Undo/Redo approach with a clean API --- README.v13.md | 139 +++++++++++++++- src/internals.js | 2 +- src/structs/Item.js | 187 ++++++++++++++------- src/types/YArray.js | 5 + src/types/YMap.js | 27 ++- src/types/YText.js | 4 + src/types/YXmlElement.js | 4 +- src/types/YXmlFragment.js | 23 ++- src/types/YXmlText.js | 3 + src/utils/DeleteSet.js | 44 +++-- src/utils/Doc.js | 1 + src/utils/StructStore.js | 68 ++++++-- src/utils/Transaction.js | 37 +++-- src/utils/UndoManager.js | 339 +++++++++++++++++++------------------- src/utils/isParentOf.js | 10 +- tests/index.js | 3 +- tests/testHelper.js | 5 +- tests/undo-redo.tests.js | 182 ++++++++++++++++++++ 18 files changed, 793 insertions(+), 290 deletions(-) create mode 100644 tests/undo-redo.tests.js diff --git a/README.v13.md b/README.v13.md index f6cd728b..b95a7215 100644 --- a/README.v13.md +++ b/README.v13.md @@ -28,6 +28,7 @@ suited for even large documents. * [Y.Doc](#Y.Doc) * [Document Updates](#Document-Updates) * [Relative Positions](#Relative-Positions) + * [Y.UndoManager](#Y.UndoManager) * [Miscellaneous](#Miscellaneous) * [Typescript Declarations](#Typescript-Declarations) * [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm) @@ -185,6 +186,8 @@ position 0.
length:number
+ forEach(function(index:number,value:object|boolean|Array|string|number|Uint8Array|Y.Type)) +
map(function(T, number, YArray):M):Array<M>
toArray():Array<object|boolean|Array|string|number|Uint8Array|Y.Type> @@ -241,11 +244,15 @@ or any of its children.
get(index:number)
- toJSON():Object<string, Object|boolean|Array|string|number> + toJSON():Object<string, Object|boolean|Array|string|number|Uint8Array>
Copies the [key,value] pairs of this YMap to a new Object.It transforms all child types to JSON using their toJSON method.
+ forEach(function(key:string,value:object|boolean|Array|string|number|Uint8Array|Y.Type)) +
+ Execute the provided function once for every key-value pair. +
[Symbol.Iterator]
Returns an Iterator of [key, value] pairs. @@ -637,6 +644,136 @@ pos.index === 2 // => true
+### Y.UndoManager + +Yjs ships with an Undo/Redo manager for selective undo/redo of of changes on a +Yjs type. The changes can be optionally scoped to transaction origins. + +```js +const ytext = doc.getArray('array') +const undoManager = new Y.UndoManager(ytext) + +ytext.insert(0, 'abc') +undoManager.undo() +ytext.toString() // => '' +undoManager.redo() +ytext.toString() // => 'abc' +``` + +
+ constructor(type:Y.AbstractType, + [trackedTransactionOrigins:Set<any>, [{captureTimeout: number}]]) +
+ undo() +
+ redo() +
+ stopCapturing() +
+ + +on('stack-item-added', { stackItem: { meta: Map<any,any> }, type: 'undo' +| 'redo' }) + + +
+Register an event that is called when a StackItem is added to the +undo- or the redo-stack. +
+ + +on('stack-item-popped', { stackItem: { meta: Map<any,any> }, type: 'undo' +| 'redo' }) + + +
+Register an event that is called when a StackItem is popped from +the undo- or the redo-stack. +
+
+ +#### Example: Stop Capturing + +UndoManager merges Undo-StackItems if they are created within time-gap +smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next +StackItem won't be merged. + +```js +// without stopCapturing +ytext.insert(0, 'a') +ytext.insert(1, 'b') +um.undo() +ytext.toString() // => '' (note that 'ab' was removed) +// with stopCapturing +ytext.insert(0, 'a') +um.stopCapturing() +ytext.insert(0, 'b') +um.undo() +ytext.toString() // => 'a' (note that only 'b' was removed) +``` + +#### Example: Specify tracked origins + +Every change on the shared document has an origin. If no origin was specified, +it defaults to `null`. By specifying `trackedTransactionOrigins` you can +selectively specify which changes should be tracked by `UndoManager`. The +UndoManager instance is always added to `trackedTransactionOrigins`. + +```js +class CustomBinding {} + +const ytext = doc.getArray('array') +const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding])) + +ytext.insert(0, 'abc') +undoManager.undo() +ytext.toString() // => 'abc' (does not track because origin `null` and not part + // of `trackedTransactionOrigins`) +ytext.delete(0, 3) // revert change + +doc.transact(() => { + ytext.insert(0, 'abc') +}, 42) +undoManager.undo() +ytext.toString() // => '' (tracked because origin is an instance of `trackedTransactionorigins`) + +doc.transact(() => { + ytext.insert(0, 'abc') +}, 41) +undoManager.undo() +ytext.toString() // => '' (not tracked because 41 is not an instance of + // `trackedTransactionorigins`) +ytext.delete(0, 3) // revert change + +doc.transact(() => { + ytext.insert(0, 'abc') +}, new CustomBinding()) +undoManager.undo() +ytext.toString() // => '' (tracked because origin is a `CustomBinding` and + // `CustomBinding` is in `trackedTransactionorigins`) +``` + +#### Example: Add additional information to the StackItems + +When undoing or redoing a previous action, it is often expected to restore +additional meta information like the cursor location or the view on the +document. You can assign meta-information to Undo-/Redo-StackItems. + +```js +const ytext = doc.getArray('array') +const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding])) + +undoManager.on('stack-item-added', event => { + // save the current cursor location on the stack-item + event.stackItem.meta.set('cursor-location', getRelativeCursorLocation()) +}) + +undoManager.on('stack-item-popped', event => { + // restore the current cursor location on the stack-item + restoreCursorLocation(event.stackItem.meta.get('cursor-location')) +}) +``` + ## Miscellaneous ### Typescript Declarations diff --git a/src/internals.js b/src/internals.js index 78d22105..74ce0eba 100644 --- a/src/internals.js +++ b/src/internals.js @@ -6,7 +6,7 @@ export * from './utils/RelativePosition.js' export * from './utils/Snapshot.js' export * from './utils/StructStore.js' export * from './utils/Transaction.js' -// export * from './utils/UndoManager.js' +export * from './utils/UndoManager.js' export * from './utils/Doc.js' export * from './utils/YEvent.js' diff --git a/src/structs/Item.js b/src/structs/Item.js index 2881d191..9ca3db07 100644 --- a/src/structs/Item.js +++ b/src/structs/Item.js @@ -33,6 +33,21 @@ import * as maplib from 'lib0/map.js' import * as set from 'lib0/set.js' import * as binary from 'lib0/binary.js' +/** + * Make sure that neither item nor any of its parents is ever deleted. + * + * This property does not persist when storing it into a database or when + * sending it to other peers + * + * @param {Item|null} item + */ +export const keepItem = item => { + while (item !== null && !item.keep) { + item.keep = true + item = item.parent._item + } +} + /** * Split leftItem into two items * @param {Transaction} transaction @@ -59,6 +74,9 @@ export const splitItem = (transaction, leftItem, diff) => { if (leftItem.deleted) { rightItem.deleted = true } + if (leftItem.keep) { + rightItem.keep = true + } // update left (do not set leftItem.rightOrigin as it will lead to problems when syncing) leftItem.right = rightItem // update right @@ -75,6 +93,106 @@ export const splitItem = (transaction, leftItem, diff) => { return rightItem } +/** + * Redoes the effect of this operation. + * + * @param {Transaction} transaction The Yjs instance. + * @param {Item} item + * @param {Set} redoitems + * + * @return {Item|null} + * + * @private + */ +export const redoItem = (transaction, item, redoitems) => { + if (item.redone !== null) { + return item.redone + } + let parentItem = item.parent._item + /** + * @type {Item|null} + */ + let left + /** + * @type {Item|null} + */ + let right + if (item.parentSub === null) { + // Is an array item. Insert at the old position + left = item.left + right = item + } else { + // Is a map item. Insert as current value + left = item + while (left.right !== null) { + left = left.right + if (left.id.client !== transaction.doc.clientID) { + // It is not possible to redo this item because it conflicts with a + // change from another client + return null + } + } + if (left.right !== null) { + left = /** @type {Item} */ (item.parent._map.get(item.parentSub)) + } + right = null + } + // make sure that parent is redone + if (parentItem !== null && parentItem.deleted === true && parentItem.redone === null) { + // try to undo parent if it will be undone anyway + if (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems) === null) { + return null + } + } + if (parentItem !== null && parentItem.redone !== null) { + while (parentItem.redone !== null) { + parentItem = parentItem.redone + } + // find next cloned_redo items + while (left !== null) { + /** + * @type {Item|null} + */ + let leftTrace = left + // trace redone until parent matches + while (leftTrace !== null && leftTrace.parent._item !== parentItem) { + leftTrace = leftTrace.redone + } + if (leftTrace !== null && leftTrace.parent._item === parentItem) { + left = leftTrace + break + } + left = left.left + } + while (right !== null) { + /** + * @type {Item|null} + */ + let rightTrace = right + // trace redone until parent matches + while (rightTrace !== null && rightTrace.parent._item !== parentItem) { + rightTrace = rightTrace.redone + } + if (rightTrace !== null && rightTrace.parent._item === parentItem) { + right = rightTrace + break + } + right = right.right + } + } + const redoneItem = new Item( + nextID(transaction), + left, left === null ? null : left.lastId, + right, right === null ? null : right.id, + parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type, + item.parentSub, + item.content.copy() + ) + item.redone = redoneItem + redoneItem.integrate(transaction) + return redoneItem +} + /** * Abstract class that represents any content. */ @@ -145,6 +263,10 @@ export class Item extends AbstractStruct { this.content = content this.length = content.getLength() this.countable = content.isCountable() + /** + * If true, do not garbage collect this Item. + */ + this.keep = false } /** @@ -270,66 +392,6 @@ export class Item extends AbstractStruct { return n } - /** - * Redoes the effect of this operation. - * - * @param {Transaction} transaction The Yjs instance. - * @param {Set} redoitems - * - * @private - */ - redo (transaction, redoitems) { - if (this.redone !== null) { - return this.redone - } - /** - * @type {any} - */ - let parent = this.parent - if (parent === null) { - return - } - let left, right - if (this.parentSub === null) { - // Is an array item. Insert at the old position - left = this.left - right = this - } else { - // Is a map item. Insert as current value - left = parent.type._map.get(this.parentSub) - right = null - } - // make sure that parent is redone - if (parent._deleted === true && parent.redone === null) { - // try to undo parent if it will be undone anyway - if (!redoitems.has(parent) || !parent.redo(transaction, redoitems)) { - return false - } - } - if (parent.redone !== null) { - while (parent.redone !== null) { - parent = parent.redone - } - // find next cloned_redo items - while (left !== null) { - if (left.redone !== null && left.redone.parent === parent) { - left = left.redone - break - } - left = left.left - } - while (right !== null) { - if (right.redone !== null && right.redone.parent === parent) { - right = right.redone - } - right = right.right - } - } - this.redone = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, this.parentSub, this.content.copy()) - this.redone.integrate(transaction) - return true - } - /** * Computes the last content address of this Item. */ @@ -350,9 +412,14 @@ export class Item extends AbstractStruct { this.id.client === right.id.client && this.id.clock + this.length === right.id.clock && this.deleted === right.deleted && + this.redone === null && + right.redone === null && this.content.constructor === right.content.constructor && this.content.mergeWith(right.content) ) { + if (right.keep) { + this.keep = true + } this.right = right.right if (this.right !== null) { this.right.left = this diff --git a/src/types/YArray.js b/src/types/YArray.js index 660659b9..f71e128c 100644 --- a/src/types/YArray.js +++ b/src/types/YArray.js @@ -68,6 +68,11 @@ export class YArray extends AbstractType { this.insert(0, /** @type {Array} */ (this._prelimContent)) this._prelimContent = null } + + _copy () { + return new YArray() + } + get length () { return this._prelimContent === null ? this._length : this._prelimContent.length } diff --git a/src/types/YMap.js b/src/types/YMap.js index afd085cf..09928956 100644 --- a/src/types/YMap.js +++ b/src/types/YMap.js @@ -72,6 +72,11 @@ export class YMap extends AbstractType { } this._prelimContent = null } + + _copy () { + return new YMap() + } + /** * Creates YMapEvent and calls observers. * @@ -106,7 +111,7 @@ export class YMap extends AbstractType { /** * Returns the keys for each element in the YMap Type. * - * @return {Iterator} + * @return {IterableIterator} */ keys () { return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[0]) @@ -115,7 +120,7 @@ export class YMap extends AbstractType { /** * Returns the keys for each element in the YMap Type. * - * @return {Iterator} + * @return {IterableIterator} */ values () { return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1]) @@ -130,6 +135,24 @@ export class YMap extends AbstractType { return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => [v[0], v[1].content.getContent()[v[1].length - 1]]) } + /** + * Executes a provided function on once on overy key-value pair. + * + * @param {function(T,string,YMap):void} f A function to execute on every element of this YArray. + */ + forEach (f) { + /** + * @type {Object} + */ + const map = {} + for (let [key, item] of this._map) { + if (!item.deleted) { + f(item.content.getContent()[item.length - 1], key, this) + } + } + return map + } + /** * @return {IterableIterator} */ diff --git a/src/types/YText.js b/src/types/YText.js index 8c46503f..167841b9 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -625,6 +625,10 @@ export class YText extends AbstractType { this._pending = null } + _copy () { + return new YText() + } + /** * Creates YTextEvent and calls observers. * diff --git a/src/types/YXmlElement.js b/src/types/YXmlElement.js index e9b419b8..3571455d 100644 --- a/src/types/YXmlElement.js +++ b/src/types/YXmlElement.js @@ -45,12 +45,10 @@ export class YXmlElement extends YXmlFragment { */ _integrate (y, item) { super._integrate(y, item) - this.insert(0, /** @type {Array} */ (this._prelimContent)) - this._prelimContent = null ;(/** @type {Map} */ (this._prelimAttrs)).forEach((value, key) => { this.setAttribute(key, value) }) - this._prelimContent = null + this._prelimAttrs = null } /** diff --git a/src/types/YXmlFragment.js b/src/types/YXmlFragment.js index d752e684..62fa543e 100644 --- a/src/types/YXmlFragment.js +++ b/src/types/YXmlFragment.js @@ -14,7 +14,7 @@ import { YXmlFragmentRefID, callTypeObservers, transact, - ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line + Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line } from '../internals.js' import * as encoding from 'lib0/encoding.js' @@ -130,6 +130,27 @@ export class YXmlFragment extends AbstractType { */ this._prelimContent = [] } + /** + * Integrate this type into the Yjs instance. + * + * * Save this struct in the os + * * This type is sent to other client + * * Observer functions are fired + * + * @param {Doc} y The Yjs instance + * @param {Item} item + * @private + */ + _integrate (y, item) { + super._integrate(y, item) + this.insert(0, /** @type {Array} */ (this._prelimContent)) + this._prelimContent = null + } + + _copy () { + return new YXmlFragment() + } + /** * Create a subtree of childNodes. * diff --git a/src/types/YXmlText.js b/src/types/YXmlText.js index 93ad7f8a..6c4088db 100644 --- a/src/types/YXmlText.js +++ b/src/types/YXmlText.js @@ -9,6 +9,9 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line * simple formatting information like bold and italic. */ export class YXmlText extends YText { + _copy () { + return new YXmlText() + } /** * Creates a Dom Element that mirrors this YXmlText. * diff --git a/src/utils/DeleteSet.js b/src/utils/DeleteSet.js index 669a6907..747d8162 100644 --- a/src/utils/DeleteSet.js +++ b/src/utils/DeleteSet.js @@ -4,7 +4,8 @@ import { createID, getState, splitItem, - Item, AbstractStruct, StructStore, Transaction, ID // eslint-disable-line + iterateStructs, + Item, GC, StructStore, Transaction, ID // eslint-disable-line } from '../internals.js' import * as math from 'lib0/math.js' @@ -47,29 +48,21 @@ export class DeleteSet { } /** - * Iterate over all structs that were deleted. - * - * This function expects that the deletes structs are not merged. Hence, you can - * probably only use it in type observes and `afterTransaction` events. But not - * in `afterTransactionCleanup`. + * Iterate over all structs that the DeleteSet gc's. * + * @param {Transaction} transaction * @param {DeleteSet} ds * @param {StructStore} store - * @param {function(AbstractStruct):void} f + * @param {function(GC|Item):void} f * * @function */ -export const iterateDeletedStructs = (ds, store, f) => +export const iterateDeletedStructs = (transaction, ds, store, f) => ds.clients.forEach((deletes, clientid) => { - const structs = /** @type {Array} */ (store.clients.get(clientid)) + const structs = /** @type {Array} */ (store.clients.get(clientid)) for (let i = 0; i < deletes.length; i++) { const del = deletes[i] - let index = findIndexSS(structs, del.clock) - let struct - do { - struct = structs[index++] - f(struct) - } while (index < structs.length && structs[index].id.clock < del.clock + del.len) + iterateStructs(transaction, structs, del.clock, del.len, f) } }) @@ -143,6 +136,27 @@ export const sortAndMergeDeleteSet = ds => { }) } +/** + * @param {DeleteSet} ds1 + * @param {DeleteSet} ds2 + * @return {DeleteSet} A fresh DeleteSet + */ +export const mergeDeleteSets = (ds1, ds2) => { + const merged = new DeleteSet() + // Write all keys from ds1 to merged. If ds2 has the same key, combine the sets. + ds1.clients.forEach((dels1, client) => + merged.clients.set(client, dels1.concat(ds2.clients.get(client) || [])) + ) + // Write all missing keys from ds2 to merged. + ds2.clients.forEach((dels2, client) => { + if (!merged.clients.has(client)) { + merged.clients.set(client, dels2) + } + }) + sortAndMergeDeleteSet(merged) + return merged +} + /** * @param {DeleteSet} ds * @param {ID} id diff --git a/src/utils/Doc.js b/src/utils/Doc.js index 40d5b4a1..3cbdc48c 100644 --- a/src/utils/Doc.js +++ b/src/utils/Doc.js @@ -27,6 +27,7 @@ export class Doc extends Observable { */ constructor (conf = {}) { super() + this.gc = conf.gc || true this.clientID = random.uint32() /** * @type {Map>} diff --git a/src/utils/StructStore.js b/src/utils/StructStore.js index 9b14d2fd..84dd4b93 100644 --- a/src/utils/StructStore.js +++ b/src/utils/StructStore.js @@ -2,7 +2,7 @@ import { GC, splitItem, - GCRef, ItemRef, Transaction, ID, Item, AbstractStruct // eslint-disable-line + GCRef, ItemRef, Transaction, ID, Item // eslint-disable-line } from '../internals.js' import * as math from 'lib0/math.js' @@ -12,7 +12,7 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line export class StructStore { constructor () { /** - * @type {Map>} + * @type {Map>} * @private */ this.clients = new Map() @@ -97,7 +97,7 @@ export const integretyCheck = store => { /** * @param {StructStore} store - * @param {AbstractStruct} struct + * @param {GC|Item} struct * * @private * @function @@ -151,14 +151,14 @@ export const findIndexSS = (structs, clock) => { * * @param {StructStore} store * @param {ID} id - * @return {AbstractStruct} + * @return {GC|Item} * * @private * @function */ export const find = (store, id) => { /** - * @type {Array} + * @type {Array} */ // @ts-ignore const structs = store.clients.get(id.client) @@ -178,6 +178,21 @@ export const find = (store, id) => { // @ts-ignore export const getItem = (store, id) => find(store, id) +/** + * @param {Transaction} transaction + * @param {Array} structs + * @param {number} clock + */ +export const findIndexCleanStart = (transaction, structs, clock) => { + const index = findIndexSS(structs, clock) + let struct = structs[index] + if (struct.id.clock < clock && struct instanceof Item) { + structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock)) + return index + 1 + } + return index +} + /** * Expects that id is actually in store. This function throws or is an infinite loop otherwise. * @@ -190,14 +205,8 @@ export const getItem = (store, id) => find(store, id) * @function */ export const getItemCleanStart = (transaction, store, id) => { - const structs = /** @type {Array} */ (store.clients.get(id.client)) - const index = findIndexSS(structs, id.clock) - let struct = structs[index] - if (struct.id.clock < id.clock && struct.constructor !== GC) { - struct = splitItem(transaction, struct, id.clock - struct.id.clock) - structs.splice(index + 1, 0, struct) - } - return struct + const structs = /** @type {Array} */ (store.clients.get(id.client)) + return /** @type {Item} */ (structs[findIndexCleanStart(transaction, structs, id.clock)]) } /** @@ -228,13 +237,40 @@ export const getItemCleanEnd = (transaction, store, id) => { /** * Replace `item` with `newitem` in store * @param {StructStore} store - * @param {AbstractStruct} struct - * @param {AbstractStruct} newStruct + * @param {GC|Item} struct + * @param {GC|Item} newStruct * * @private * @function */ export const replaceStruct = (store, struct, newStruct) => { - const structs = /** @type {Array} */ (store.clients.get(struct.id.client)) + const structs = /** @type {Array} */ (store.clients.get(struct.id.client)) structs[findIndexSS(structs, struct.id.clock)] = newStruct } + +/** + * Iterate over a range of structs + * + * @param {Transaction} transaction + * @param {Array} structs + * @param {number} clockStart Inclusive start + * @param {number} len + * @param {function(GC|Item):void} f + * + * @function + */ +export const iterateStructs = (transaction, structs, clockStart, len, f) => { + if (len === 0) { + return + } + const clockEnd = clockStart + len + let index = findIndexCleanStart(transaction, structs, clockStart) + let struct + do { + struct = structs[index++] + if (clockEnd < struct.id.clock + struct.length) { + findIndexCleanStart(transaction, structs, clockEnd) + } + f(struct) + } while (index < structs.length && structs[index].id.clock < clockEnd) +} diff --git a/src/utils/Transaction.js b/src/utils/Transaction.js index d5ccc833..2db80eeb 100644 --- a/src/utils/Transaction.js +++ b/src/utils/Transaction.js @@ -207,23 +207,26 @@ export const transact = (doc, f, origin = null) => { } } } - // replace deleted items with ItemDeleted / GC - for (const [client, deleteItems] of ds.clients) { - const structs = /** @type {Array} */ (store.clients.get(client)) - for (let di = deleteItems.length - 1; di >= 0; di--) { - const deleteItem = deleteItems[di] - const endDeleteItemClock = deleteItem.clock + deleteItem.len - for ( - let si = findIndexSS(structs, deleteItem.clock), struct = structs[si]; - si < structs.length && struct.id.clock < endDeleteItemClock; - struct = structs[++si] - ) { - const struct = structs[si] - if (deleteItem.clock + deleteItem.len <= struct.id.clock) { - break - } - if (struct.deleted && struct instanceof Item) { - struct.gc(store, false) + // Replace deleted items with ItemDeleted / GC. + // This is where content is actually remove from the Yjs Doc. + if (doc.gc) { + for (const [client, deleteItems] of ds.clients) { + const structs = /** @type {Array} */ (store.clients.get(client)) + for (let di = deleteItems.length - 1; di >= 0; di--) { + const deleteItem = deleteItems[di] + const endDeleteItemClock = deleteItem.clock + deleteItem.len + for ( + let si = findIndexSS(structs, deleteItem.clock), struct = structs[si]; + si < structs.length && struct.id.clock < endDeleteItemClock; + struct = structs[++si] + ) { + const struct = structs[si] + if (deleteItem.clock + deleteItem.len <= struct.id.clock) { + break + } + if (struct instanceof Item && struct.deleted && !struct.keep) { + struct.gc(store, false) + } } } } diff --git a/src/utils/UndoManager.js b/src/utils/UndoManager.js index 3d7984c6..54d6611d 100644 --- a/src/utils/UndoManager.js +++ b/src/utils/UndoManager.js @@ -1,202 +1,207 @@ -// @ts-nocheck - import { + mergeDeleteSets, + iterateDeletedStructs, + keepItem, + transact, + redoItem, + iterateStructs, isParentOf, - createID, - transact + Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line } from '../internals.js' -/** - * @private - */ -class ReverseOperation { - constructor (y, transaction, bindingInfos) { - this.created = new Date() - const beforeState = transaction.beforeState - if (beforeState.has(y.userID)) { - this.toState = createID(y.userID, y.ss.getState(y.userID) - 1) - this.fromState = createID(y.userID, beforeState.get(y.userID)) - } else { - this.toState = null - this.fromState = null - } - this.deletedStructs = new Set() - transaction.deletedStructs.forEach(struct => { - this.deletedStructs.add({ - from: struct._id, - len: struct._length - }) - }) +import * as time from 'lib0/time.js' +import { Observable } from 'lib0/observable' + +class StackItem { + /** + * @param {DeleteSet} ds + * @param {number} start clock start of the local client + * @param {number} len + */ + constructor (ds, start, len) { + this.ds = ds + this.start = start + this.len = len /** - * Maps from binding to binding information (e.g. cursor information) + * Use this to save and restore metadata like selection range */ - this.bindingInfos = bindingInfos + this.meta = new Map() } } /** - * @private - * @function + * @param {UndoManager} undoManager + * @param {Array} stack + * @param {string} eventType + * @return {StackItem?} */ -function applyReverseOperation (y, scope, reverseBuffer) { - let performedUndo = false - let undoOp = null - transact(y, () => { - while (!performedUndo && reverseBuffer.length > 0) { - undoOp = reverseBuffer.pop() - // make sure that it is possible to iterate {from}-{to} - if (undoOp.fromState !== null) { - y.os.getItemCleanStart(undoOp.fromState) - y.os.getItemCleanEnd(undoOp.toState) - y.os.iterate(undoOp.fromState, undoOp.toState, op => { - while (op._deleted && op._redone !== null) { - op = op._redone - } - if (op._deleted === false && isParentOf(scope, op)) { - performedUndo = true - op._delete(y) - } - }) - } - const redoitems = new Set() - for (let del of undoOp.deletedStructs) { - const fromState = del.from - const toState = createID(fromState.user, fromState.clock + del.len - 1) - y.os.getItemCleanStart(fromState) - y.os.getItemCleanEnd(toState) - y.os.iterate(fromState, toState, op => { - if ( - isParentOf(scope, op) && - op._parent !== y && - ( - op._id.user !== y.userID || - undoOp.fromState === null || - op._id.clock < undoOp.fromState.clock || - op._id.clock > undoOp.toState.clock - ) - ) { - redoitems.add(op) - } - }) - } - redoitems.forEach(op => { - const opUndone = op._redo(y, redoitems) - performedUndo = performedUndo || opUndone - }) - } - }) - if (performedUndo && undoOp !== null) { - // should be performed after the undo transaction - undoOp.bindingInfos.forEach((info, binding) => { - binding._restoreUndoStackInfo(info) - }) - } - return performedUndo -} - -/** - * Saves a history of locally applied operations. The UndoManager handles the - * undoing and redoing of locally created changes. - * - * @private - * @function - */ -export class UndoManager { +const popStackItem = (undoManager, stack, eventType) => { /** - * @param {YType} scope The scope on which to listen for changes. - * @param {Object} options Optionally provided configuration. + * Whether a change happened + * @type {StackItem?} */ - constructor (scope, options = {}) { - this.options = options - this._bindings = new Set(options.bindings) - options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout - this._undoBuffer = [] - this._redoBuffer = [] - this._scope = scope - this._undoing = false - this._redoing = false - this._lastTransactionWasUndo = false - const doc = scope.doc - this.y = doc - let bindingInfos - doc.on('beforeTransaction', (y, transaction, remote) => { - if (!remote) { - // Store binding information before transaction is executed - // By restoring the binding information, we can make sure that the state - // before the transaction can be recovered - bindingInfos = new Map() - this._bindings.forEach(binding => { - bindingInfos.set(binding, binding._getUndoStackInfo()) - }) - } - }) - doc.on('afterTransaction', (y, transaction, remote) => { - if (!remote && transaction.changedParentTypes.has(scope)) { - let reverseOperation = new ReverseOperation(y, transaction, bindingInfos) - if (!this._undoing) { - let lastUndoOp = this._undoBuffer.length > 0 ? this._undoBuffer[this._undoBuffer.length - 1] : null - if ( - this._redoing === false && - this._lastTransactionWasUndo === false && - lastUndoOp !== null && - ((options.captureTimeout < 0) || (reverseOperation.created.getTime() - lastUndoOp.created.getTime()) <= options.captureTimeout) - ) { - lastUndoOp.created = reverseOperation.created - if (reverseOperation.toState !== null) { - lastUndoOp.toState = reverseOperation.toState - if (lastUndoOp.fromState === null) { - lastUndoOp.fromState = reverseOperation.fromState - } - } - reverseOperation.deletedStructs.forEach(lastUndoOp.deletedStructs.add, lastUndoOp.deletedStructs) - } else { - this._lastTransactionWasUndo = false - this._undoBuffer.push(reverseOperation) - } - if (!this._redoing) { - this._redoBuffer = [] - } - } else { - this._lastTransactionWasUndo = true - this._redoBuffer.push(reverseOperation) + let result = null + const doc = undoManager.doc + const type = undoManager.type + transact(doc, transaction => { + while (stack.length > 0 && result === null) { + const store = doc.store + const stackItem = /** @type {StackItem} */ (stack.pop()) + const itemsToRedo = new Set() + let performedChange = false + iterateDeletedStructs(transaction, stackItem.ds, store, struct => { + if (struct instanceof Item && isParentOf(type, struct)) { + itemsToRedo.add(struct) } + }) + itemsToRedo.forEach(item => { + performedChange = redoItem(transaction, item, itemsToRedo) !== null || performedChange + }) + const structs = /** @type {Array} */ (store.clients.get(doc.clientID)) + iterateStructs(transaction, structs, stackItem.start, stackItem.len, struct => { + if (!struct.deleted && isParentOf(type, /** @type {Item} */ (struct))) { + struct.delete(transaction) + performedChange = true + } + }) + result = stackItem + } + }, undoManager) + if (result != null) { + undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager]) + } + return result +} + +/** + * Fires 'stack-item-added' event when a stack item was added to either the undo- or + * the redo-stack. You may store additional stack information via the + * metadata property on `event.stackItem.metadata` (it is a `Map` of metadata properties). + * Fires 'stack-item-popped' event when a stack item was popped from either the + * undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.metadata`. + * + * @extends {Observable<'stack-item-added'|'stack-item-popped'>} + */ +export class UndoManager extends Observable { + /** + * @param {AbstractType} type + * @param {Set} [trackedTransactionOrigins=new Set([null])] + * @param {object} [options={captureTimeout=500}] + */ + constructor (type, trackedTransactionOrigins = new Set([null]), { captureTimeout = 500 } = {}) { + super() + this.type = type + trackedTransactionOrigins.add(this) + this.trackedTransactionOrigins = trackedTransactionOrigins + /** + * @type {Array} + */ + this.undoStack = [] + /** + * @type {Array} + */ + this.redoStack = [] + /** + * Whether the client is currently undoing (calling UndoManager.undo) + * + * @type {boolean} + */ + this.undoing = false + this.redoing = false + this.doc = /** @type {Doc} */ (type.doc) + this.lastChange = 0 + type.observeDeep((events, transaction) => { + // Only track certain transactions + if (!this.trackedTransactionOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedTransactionOrigins.has(transaction.origin.constructor))) { + return } + const undoing = this.undoing + const redoing = this.redoing + const stack = undoing ? this.redoStack : this.undoStack + if (undoing) { + this.stopCapturing() // next undo should not be appended to last stack item + } else if (!redoing) { + // neither undoing nor redoing: delete redoStack + this.redoStack = [] + } + const beforeState = transaction.beforeState.get(this.doc.clientID) || 0 + const afterState = transaction.afterState.get(this.doc.clientID) || 0 + const now = time.getUnixTime() + if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) { + // append change to last stack op + const lastOp = stack[stack.length - 1] + lastOp.ds = mergeDeleteSets(lastOp.ds, transaction.deleteSet) + lastOp.len = afterState - lastOp.start + } else { + // create a new stack op + stack.push(new StackItem(transaction.deleteSet, beforeState, afterState - beforeState)) + } + if (!undoing && !redoing) { + this.lastChange = now + } + // make sure that deleted structs are not gc'd + iterateDeletedStructs(transaction, transaction.deleteSet, transaction.doc.store, /** @param {Item|GC} item */ item => { + if (item instanceof Item && isParentOf(type, item)) { + keepItem(item) + } + }) + this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this]) }) } /** - * Enforce that the next change is created as a separate item in the undo stack + * UndoManager merges Undo-StackItem if they are created within time-gap + * smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next + * StackItem won't be merged. + * + * + * @example + * // without stopCapturing + * ytext.insert(0, 'a') + * ytext.insert(1, 'b') + * um.undo() + * ytext.toString() // => '' (note that 'ab' was removed) + * // with stopCapturing + * ytext.insert(0, 'a') + * um.stopCapturing() + * ytext.insert(0, 'b') + * um.undo() + * ytext.toString() // => 'a' (note that only 'b' was removed) * - * @private - * @function */ - flushChanges () { - this._lastTransactionWasUndo = true + stopCapturing () { + this.lastChange = 0 } /** - * Undo the last locally created change. + * Undo last changes on type. * - * @private - * @function + * @return {StackItem?} Returns StackItem if a change was applied */ undo () { - this._undoing = true - const performedUndo = applyReverseOperation(this.y, this._scope, this._undoBuffer) - this._undoing = false - return performedUndo + this.undoing = true + let res + try { + res = popStackItem(this, this.undoStack, 'undo') + } finally { + this.undoing = false + } + return res } /** - * Redo the last locally created change. + * Redo last undo operation. * - * @private - * @function + * @return {StackItem?} Returns StackItem if a change was applied */ redo () { - this._redoing = true - const performedRedo = applyReverseOperation(this.y, this._scope, this._redoBuffer) - this._redoing = false - return performedRedo + this.redoing = true + let res + try { + res = popStackItem(this, this.redoStack, 'redo') + } finally { + this.redoing = false + } + return res } } diff --git a/src/utils/isParentOf.js b/src/utils/isParentOf.js index 083eb4c7..ba3ebb5e 100644 --- a/src/utils/isParentOf.js +++ b/src/utils/isParentOf.js @@ -1,22 +1,22 @@ -import { AbstractType } from '../internals.js' // eslint-disable-line +import { AbstractType, Item } from '../internals.js' // eslint-disable-line /** * Check if `parent` is a parent of `child`. * * @param {AbstractType} parent - * @param {AbstractType} child + * @param {Item|null} child * @return {Boolean} Whether `parent` is a parent of `child`. * * @private * @function */ export const isParentOf = (parent, child) => { - while (child._item !== null) { - if (child === parent) { + while (child !== null) { + if (child.parent === parent) { return true } - child = child._item.parent + child = child.parent._item } return false } diff --git a/tests/index.js b/tests/index.js index d2b8e9ef..7d60fa26 100644 --- a/tests/index.js +++ b/tests/index.js @@ -4,6 +4,7 @@ import * as map from './y-map.tests.js' import * as text from './y-text.tests.js' import * as xml from './y-xml.tests.js' import * as encoding from './encoding.tests.js' +import * as undoredo from './undo-redo.tests.js' import { runTests } from 'lib0/testing.js' import { isBrowser, isNode } from 'lib0/environment.js' @@ -13,7 +14,7 @@ if (isBrowser) { log.createVConsole(document.body) } runTests({ - map, array, text, xml, encoding + map, array, text, xml, encoding, undoredo }).then(success => { /* istanbul ignore next */ if (isNode) { diff --git a/tests/testHelper.js b/tests/testHelper.js index 56639a39..913e9878 100644 --- a/tests/testHelper.js +++ b/tests/testHelper.js @@ -266,8 +266,11 @@ export const compare = users => { t.assert(u.store.pendingClientsStructRefs.size === 0) } // Test Array iterator - t.compare(userArrayValues[0], Array.from(users[0].getArray('array').toJSON())) + t.compare(users[0].getArray('array').toArray(), Array.from(users[0].getArray('array'))) // Test Map iterator + const ymapkeys = Array.from(users[0].getMap('map').keys()) + t.assert(ymapkeys.length === Object.keys(userMapValues[0]).length) + ymapkeys.forEach(key => t.assert(userMapValues[0].hasOwnProperty(key))) /** * @type {Object} */ diff --git a/tests/undo-redo.tests.js b/tests/undo-redo.tests.js new file mode 100644 index 00000000..8f622bd5 --- /dev/null +++ b/tests/undo-redo.tests.js @@ -0,0 +1,182 @@ +import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line + +import { + UndoManager +} from '../src/internals.js' + +import * as Y from '../src/index.js' +import * as t from 'lib0/testing.js' + +/** + * @param {t.TestCase} tc + */ +export const testUndoText = tc => { + const { testConnector, text0, text1 } = init(tc, { users: 3 }) + const undoManager = new UndoManager(text0) + text0.insert(0, 'abc') + text1.insert(0, 'xyz') + testConnector.syncAll() + undoManager.undo() + t.assert(text0.toString() === 'xyz') + undoManager.redo() + t.assert(text0.toString() === 'abcxyz') + testConnector.syncAll() + text1.delete(0, 1) + testConnector.syncAll() + undoManager.undo() + t.assert(text0.toString() === 'xyz') + undoManager.redo() + t.assert(text0.toString() === 'bcxyz') + // test marks + text0.format(1, 3, { bold: true }) + t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }]) + undoManager.undo() + t.compare(text0.toDelta(), [{ insert: 'bcxyz' }]) + undoManager.redo() + t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }]) +} + +/** + * @param {t.TestCase} tc + */ +export const testUndoMap = tc => { + const { testConnector, map0, map1 } = init(tc, { users: 2 }) + map0.set('a', 0) + const undoManager = new UndoManager(map0) + map0.set('a', 1) + undoManager.undo() + t.assert(map0.get('a') === 0) + undoManager.redo() + t.assert(map0.get('a') === 1) + // testing sub-types and if it can restore a whole type + const subType = new Y.Map() + map0.set('a', subType) + subType.set('x', 42) + t.compare(map0.toJSON(), /** @type {any} */ ({ 'a': { x: 42 } })) + undoManager.undo() + t.assert(map0.get('a') === 1) + undoManager.redo() + t.compare(map0.toJSON(), /** @type {any} */ ({ 'a': { x: 42 } })) + testConnector.syncAll() + // if content is overwritten by another user, undo operations should be skipped + map1.set('a', 44) + testConnector.syncAll() + undoManager.undo() + t.assert(map0.get('a') === 44) + undoManager.redo() + t.assert(map0.get('a') === 44) +} + +/** + * @param {t.TestCase} tc + */ +export const testUndoArray = tc => { + const { testConnector, array0, array1 } = init(tc, { users: 3 }) + const undoManager = new UndoManager(array0) + array0.insert(0, [1, 2, 3]) + array1.insert(0, [4, 5, 6]) + testConnector.syncAll() + t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6]) + undoManager.undo() + t.compare(array0.toArray(), [4, 5, 6]) + undoManager.redo() + t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6]) + testConnector.syncAll() + array1.delete(0, 1) // user1 deletes [1] + testConnector.syncAll() + undoManager.undo() + t.compare(array0.toArray(), [4, 5, 6]) + undoManager.redo() + t.compare(array0.toArray(), [2, 3, 4, 5, 6]) + array0.delete(0, 5) + // test nested structure + const ymap = new Y.Map() + array0.insert(0, [ymap]) + t.compare(array0.toJSON(), [{}]) + undoManager.stopCapturing() + ymap.set('a', 1) + t.compare(array0.toJSON(), [{ a: 1 }]) + undoManager.undo() + t.compare(array0.toJSON(), [{}]) + undoManager.undo() + t.compare(array0.toJSON(), [2, 3, 4, 5, 6]) + undoManager.redo() + t.compare(array0.toJSON(), [{}]) + undoManager.redo() + t.compare(array0.toJSON(), [{ a: 1 }]) + testConnector.syncAll() + array1.get(0).set('b', 2) + testConnector.syncAll() + t.compare(array0.toJSON(), [{ a: 1, b: 2 }]) + undoManager.undo() + t.compare(array0.toJSON(), [{ b: 2 }]) + undoManager.undo() + t.compare(array0.toJSON(), [2, 3, 4, 5, 6]) + undoManager.redo() + t.compare(array0.toJSON(), [{ b: 2 }]) + undoManager.redo() + t.compare(array0.toJSON(), [{ a: 1, b: 2 }]) +} + +/** + * @param {t.TestCase} tc + */ +export const testUndoXml = tc => { + const { xml0 } = init(tc, { users: 3 }) + const undoManager = new UndoManager(xml0) + const child = new Y.XmlElement('p') + xml0.insert(0, [child]) + const textchild = new Y.XmlText('content') + child.insert(0, [textchild]) + t.assert(xml0.toString() === '

content

') + // format textchild and revert that change + undoManager.stopCapturing() + textchild.format(3, 4, { bold: {} }) + t.assert(xml0.toString() === '

content

') + undoManager.undo() + t.assert(xml0.toString() === '

content

') + undoManager.redo() + t.assert(xml0.toString() === '

content

') + xml0.delete(0, 1) + t.assert(xml0.toString() === '') + undoManager.undo() + t.assert(xml0.toString() === '

content

') +} + +/** + * @param {t.TestCase} tc + */ +export const testUndoEvents = tc => { + const { text0 } = init(tc, { users: 3 }) + const undoManager = new UndoManager(text0) + let counter = 0 + let receivedMetadata = -1 + undoManager.on('stack-item-added', /** @param {any} event */ event => { + t.assert(event.type != null) + event.stackItem.meta.set('test', counter++) + }) + undoManager.on('stack-item-popped', /** @param {any} event */ event => { + t.assert(event.type != null) + receivedMetadata = event.stackItem.meta.get('test') + }) + text0.insert(0, 'abc') + undoManager.undo() + t.assert(receivedMetadata === 0) + undoManager.redo() + t.assert(receivedMetadata === 1) +} + +/** + * @param {t.TestCase} tc + */ +export const testTrackClass = tc => { + const { users, text0 } = init(tc, { users: 3 }) + // only track origins that are numbers + const undoManager = new UndoManager(text0, new Set([Number])) + users[0].transact(() => { + text0.insert(0, 'abc') + }, 42) + t.assert(text0.toString() === 'abc') + undoManager.undo() + t.assert(text0.toString() === '') +}