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() === '')
+}