Port Undo/Redo approach with a clean API

This commit is contained in:
Kevin Jahns 2019-06-23 13:04:14 +02:00
parent 14df5b72af
commit 03458dc641
18 changed files with 793 additions and 290 deletions

View File

@ -28,6 +28,7 @@ suited for even large documents.
* [Y.Doc](#Y.Doc) * [Y.Doc](#Y.Doc)
* [Document Updates](#Document-Updates) * [Document Updates](#Document-Updates)
* [Relative Positions](#Relative-Positions) * [Relative Positions](#Relative-Positions)
* [Y.UndoManager](#Y.UndoManager)
* [Miscellaneous](#Miscellaneous) * [Miscellaneous](#Miscellaneous)
* [Typescript Declarations](#Typescript-Declarations) * [Typescript Declarations](#Typescript-Declarations)
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm) * [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
@ -185,6 +186,8 @@ position 0.
<dd></dd> <dd></dd>
<b><code>length:number</code></b> <b><code>length:number</code></b>
<dd></dd> <dd></dd>
<b><code>forEach(function(index:number,value:object|boolean|Array|string|number|Uint8Array|Y.Type))</code></b>
<dd></dd>
<b><code>map(function(T, number, YArray):M):Array&lt;M&gt;</code></b> <b><code>map(function(T, number, YArray):M):Array&lt;M&gt;</code></b>
<dd></dd> <dd></dd>
<b><code>toArray():Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;</code></b> <b><code>toArray():Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;</code></b>
@ -241,11 +244,15 @@ or any of its children.
<dd></dd> <dd></dd>
<b><code>get(index:number)</code></b> <b><code>get(index:number)</code></b>
<dd></dd> <dd></dd>
<b><code>toJSON():Object&lt;string, Object|boolean|Array|string|number&gt;</code></b> <b><code>toJSON():Object&lt;string, Object|boolean|Array|string|number|Uint8Array&gt;</code></b>
<dd> <dd>
Copies the <code>[key,value]</code> pairs of this YMap to a new Object.It Copies the <code>[key,value]</code> pairs of this YMap to a new Object.It
transforms all child types to JSON using their <code>toJSON</code> method. transforms all child types to JSON using their <code>toJSON</code> method.
</dd> </dd>
<b><code>forEach(function(key:string,value:object|boolean|Array|string|number|Uint8Array|Y.Type))</code></b>
<dd>
Execute the provided function once for every key-value pair.
</dd>
<b><code>[Symbol.Iterator]</code></b> <b><code>[Symbol.Iterator]</code></b>
<dd> <dd>
Returns an Iterator of <code>[key, value]</code> pairs. Returns an Iterator of <code>[key, value]</code> pairs.
@ -637,6 +644,136 @@ pos.index === 2 // => true
<dd></dd> <dd></dd>
</dl> </dl>
### 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'
```
<dl>
<b><code>constructor(type:Y.AbstractType,
[trackedTransactionOrigins:Set&lt;any&gt;, [{captureTimeout: number}]])</code></b>
<dd></dd>
<b><code>undo()</code></b>
<dd></dd>
<b><code>redo()</code></b>
<dd></dd>
<b><code>stopCapturing()</code></b>
<dd></dd>
<b>
<code>
on('stack-item-added', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
| 'redo' })
</code>
</b>
<dd>
Register an event that is called when a <code>StackItem</code> is added to the
undo- or the redo-stack.
</dd>
<b>
<code>
on('stack-item-popped', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
| 'redo' })
</code>
</b>
<dd>
Register an event that is called when a <code>StackItem</code> is popped from
the undo- or the redo-stack.
</dd>
</dl>
#### 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 ## Miscellaneous
### Typescript Declarations ### Typescript Declarations

View File

@ -6,7 +6,7 @@ export * from './utils/RelativePosition.js'
export * from './utils/Snapshot.js' export * from './utils/Snapshot.js'
export * from './utils/StructStore.js' export * from './utils/StructStore.js'
export * from './utils/Transaction.js' export * from './utils/Transaction.js'
// export * from './utils/UndoManager.js' export * from './utils/UndoManager.js'
export * from './utils/Doc.js' export * from './utils/Doc.js'
export * from './utils/YEvent.js' export * from './utils/YEvent.js'

View File

@ -33,6 +33,21 @@ import * as maplib from 'lib0/map.js'
import * as set from 'lib0/set.js' import * as set from 'lib0/set.js'
import * as binary from 'lib0/binary.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 * Split leftItem into two items
* @param {Transaction} transaction * @param {Transaction} transaction
@ -59,6 +74,9 @@ export const splitItem = (transaction, leftItem, diff) => {
if (leftItem.deleted) { if (leftItem.deleted) {
rightItem.deleted = true rightItem.deleted = true
} }
if (leftItem.keep) {
rightItem.keep = true
}
// update left (do not set leftItem.rightOrigin as it will lead to problems when syncing) // update left (do not set leftItem.rightOrigin as it will lead to problems when syncing)
leftItem.right = rightItem leftItem.right = rightItem
// update right // update right
@ -75,6 +93,106 @@ export const splitItem = (transaction, leftItem, diff) => {
return rightItem return rightItem
} }
/**
* Redoes the effect of this operation.
*
* @param {Transaction} transaction The Yjs instance.
* @param {Item} item
* @param {Set<Item>} 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. * Abstract class that represents any content.
*/ */
@ -145,6 +263,10 @@ export class Item extends AbstractStruct {
this.content = content this.content = content
this.length = content.getLength() this.length = content.getLength()
this.countable = content.isCountable() 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 return n
} }
/**
* Redoes the effect of this operation.
*
* @param {Transaction} transaction The Yjs instance.
* @param {Set<Item>} 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. * 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.client === right.id.client &&
this.id.clock + this.length === right.id.clock && this.id.clock + this.length === right.id.clock &&
this.deleted === right.deleted && this.deleted === right.deleted &&
this.redone === null &&
right.redone === null &&
this.content.constructor === right.content.constructor && this.content.constructor === right.content.constructor &&
this.content.mergeWith(right.content) this.content.mergeWith(right.content)
) { ) {
if (right.keep) {
this.keep = true
}
this.right = right.right this.right = right.right
if (this.right !== null) { if (this.right !== null) {
this.right.left = this this.right.left = this

View File

@ -68,6 +68,11 @@ export class YArray extends AbstractType {
this.insert(0, /** @type {Array} */ (this._prelimContent)) this.insert(0, /** @type {Array} */ (this._prelimContent))
this._prelimContent = null this._prelimContent = null
} }
_copy () {
return new YArray()
}
get length () { get length () {
return this._prelimContent === null ? this._length : this._prelimContent.length return this._prelimContent === null ? this._length : this._prelimContent.length
} }

View File

@ -72,6 +72,11 @@ export class YMap extends AbstractType {
} }
this._prelimContent = null this._prelimContent = null
} }
_copy () {
return new YMap()
}
/** /**
* Creates YMapEvent and calls observers. * Creates YMapEvent and calls observers.
* *
@ -106,7 +111,7 @@ export class YMap extends AbstractType {
/** /**
* Returns the keys for each element in the YMap Type. * Returns the keys for each element in the YMap Type.
* *
* @return {Iterator<string>} * @return {IterableIterator<string>}
*/ */
keys () { keys () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[0]) 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. * Returns the keys for each element in the YMap Type.
* *
* @return {Iterator<string>} * @return {IterableIterator<string>}
*/ */
values () { values () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1]) 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]]) 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<T>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
/**
* @type {Object<string,T>}
*/
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<T>} * @return {IterableIterator<T>}
*/ */

View File

@ -625,6 +625,10 @@ export class YText extends AbstractType {
this._pending = null this._pending = null
} }
_copy () {
return new YText()
}
/** /**
* Creates YTextEvent and calls observers. * Creates YTextEvent and calls observers.
* *

View File

@ -45,12 +45,10 @@ export class YXmlElement extends YXmlFragment {
*/ */
_integrate (y, item) { _integrate (y, item) {
super._integrate(y, item) super._integrate(y, item)
this.insert(0, /** @type {Array} */ (this._prelimContent))
this._prelimContent = null
;(/** @type {Map<string, any>} */ (this._prelimAttrs)).forEach((value, key) => { ;(/** @type {Map<string, any>} */ (this._prelimAttrs)).forEach((value, key) => {
this.setAttribute(key, value) this.setAttribute(key, value)
}) })
this._prelimContent = null this._prelimAttrs = null
} }
/** /**

View File

@ -14,7 +14,7 @@ import {
YXmlFragmentRefID, YXmlFragmentRefID,
callTypeObservers, callTypeObservers,
transact, transact,
ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
@ -130,6 +130,27 @@ export class YXmlFragment extends AbstractType {
*/ */
this._prelimContent = [] 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. * Create a subtree of childNodes.
* *

View File

@ -9,6 +9,9 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
* simple formatting information like bold and italic. * simple formatting information like bold and italic.
*/ */
export class YXmlText extends YText { export class YXmlText extends YText {
_copy () {
return new YXmlText()
}
/** /**
* Creates a Dom Element that mirrors this YXmlText. * Creates a Dom Element that mirrors this YXmlText.
* *

View File

@ -4,7 +4,8 @@ import {
createID, createID,
getState, getState,
splitItem, splitItem,
Item, AbstractStruct, StructStore, Transaction, ID // eslint-disable-line iterateStructs,
Item, GC, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as math from 'lib0/math.js' import * as math from 'lib0/math.js'
@ -47,29 +48,21 @@ export class DeleteSet {
} }
/** /**
* Iterate over all structs that were deleted. * Iterate over all structs that the DeleteSet gc's.
*
* 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`.
* *
* @param {Transaction} transaction
* @param {DeleteSet} ds * @param {DeleteSet} ds
* @param {StructStore} store * @param {StructStore} store
* @param {function(AbstractStruct):void} f * @param {function(GC|Item):void} f
* *
* @function * @function
*/ */
export const iterateDeletedStructs = (ds, store, f) => export const iterateDeletedStructs = (transaction, ds, store, f) =>
ds.clients.forEach((deletes, clientid) => { ds.clients.forEach((deletes, clientid) => {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(clientid)) const structs = /** @type {Array<GC|Item>} */ (store.clients.get(clientid))
for (let i = 0; i < deletes.length; i++) { for (let i = 0; i < deletes.length; i++) {
const del = deletes[i] const del = deletes[i]
let index = findIndexSS(structs, del.clock) iterateStructs(transaction, structs, del.clock, del.len, f)
let struct
do {
struct = structs[index++]
f(struct)
} while (index < structs.length && structs[index].id.clock < del.clock + del.len)
} }
}) })
@ -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 {DeleteSet} ds
* @param {ID} id * @param {ID} id

View File

@ -27,6 +27,7 @@ export class Doc extends Observable {
*/ */
constructor (conf = {}) { constructor (conf = {}) {
super() super()
this.gc = conf.gc || true
this.clientID = random.uint32() this.clientID = random.uint32()
/** /**
* @type {Map<string, AbstractType<YEvent>>} * @type {Map<string, AbstractType<YEvent>>}

View File

@ -2,7 +2,7 @@
import { import {
GC, GC,
splitItem, splitItem,
GCRef, ItemRef, Transaction, ID, Item, AbstractStruct // eslint-disable-line GCRef, ItemRef, Transaction, ID, Item // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as math from 'lib0/math.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 { export class StructStore {
constructor () { constructor () {
/** /**
* @type {Map<number,Array<AbstractStruct>>} * @type {Map<number,Array<GC|Item>>}
* @private * @private
*/ */
this.clients = new Map() this.clients = new Map()
@ -97,7 +97,7 @@ export const integretyCheck = store => {
/** /**
* @param {StructStore} store * @param {StructStore} store
* @param {AbstractStruct} struct * @param {GC|Item} struct
* *
* @private * @private
* @function * @function
@ -151,14 +151,14 @@ export const findIndexSS = (structs, clock) => {
* *
* @param {StructStore} store * @param {StructStore} store
* @param {ID} id * @param {ID} id
* @return {AbstractStruct} * @return {GC|Item}
* *
* @private * @private
* @function * @function
*/ */
export const find = (store, id) => { export const find = (store, id) => {
/** /**
* @type {Array<AbstractStruct>} * @type {Array<GC|Item>}
*/ */
// @ts-ignore // @ts-ignore
const structs = store.clients.get(id.client) const structs = store.clients.get(id.client)
@ -178,6 +178,21 @@ export const find = (store, id) => {
// @ts-ignore // @ts-ignore
export const getItem = (store, id) => find(store, id) export const getItem = (store, id) => find(store, id)
/**
* @param {Transaction} transaction
* @param {Array<Item|GC>} 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. * 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 * @function
*/ */
export const getItemCleanStart = (transaction, store, id) => { export const getItemCleanStart = (transaction, store, id) => {
const structs = /** @type {Array<Item>} */ (store.clients.get(id.client)) const structs = /** @type {Array<GC|Item>} */ (store.clients.get(id.client))
const index = findIndexSS(structs, id.clock) return /** @type {Item} */ (structs[findIndexCleanStart(transaction, 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
} }
/** /**
@ -228,13 +237,40 @@ export const getItemCleanEnd = (transaction, store, id) => {
/** /**
* Replace `item` with `newitem` in store * Replace `item` with `newitem` in store
* @param {StructStore} store * @param {StructStore} store
* @param {AbstractStruct} struct * @param {GC|Item} struct
* @param {AbstractStruct} newStruct * @param {GC|Item} newStruct
* *
* @private * @private
* @function * @function
*/ */
export const replaceStruct = (store, struct, newStruct) => { export const replaceStruct = (store, struct, newStruct) => {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(struct.id.client)) const structs = /** @type {Array<GC|Item>} */ (store.clients.get(struct.id.client))
structs[findIndexSS(structs, struct.id.clock)] = newStruct structs[findIndexSS(structs, struct.id.clock)] = newStruct
} }
/**
* Iterate over a range of structs
*
* @param {Transaction} transaction
* @param {Array<Item|GC>} 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)
}

View File

@ -207,7 +207,9 @@ export const transact = (doc, f, origin = null) => {
} }
} }
} }
// replace deleted items with ItemDeleted / GC // 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) { for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client)) const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) { for (let di = deleteItems.length - 1; di >= 0; di--) {
@ -222,12 +224,13 @@ export const transact = (doc, f, origin = null) => {
if (deleteItem.clock + deleteItem.len <= struct.id.clock) { if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break break
} }
if (struct.deleted && struct instanceof Item) { if (struct instanceof Item && struct.deleted && !struct.keep) {
struct.gc(store, false) struct.gc(store, false)
} }
} }
} }
} }
}
// try to merge deleted / gc'd items // try to merge deleted / gc'd items
// merge from right to left for better efficiecy and so we don't miss any merge targets // merge from right to left for better efficiecy and so we don't miss any merge targets
for (const [client, deleteItems] of ds.clients) { for (const [client, deleteItems] of ds.clients) {

View File

@ -1,202 +1,207 @@
// @ts-nocheck
import { import {
mergeDeleteSets,
iterateDeletedStructs,
keepItem,
transact,
redoItem,
iterateStructs,
isParentOf, isParentOf,
createID, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
transact
} from '../internals.js' } from '../internals.js'
import * as time from 'lib0/time.js'
import { Observable } from 'lib0/observable'
class StackItem {
/** /**
* @private * @param {DeleteSet} ds
* @param {number} start clock start of the local client
* @param {number} len
*/ */
class ReverseOperation { constructor (ds, start, len) {
constructor (y, transaction, bindingInfos) { this.ds = ds
this.created = new Date() this.start = start
const beforeState = transaction.beforeState this.len = len
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
})
})
/** /**
* 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 * @param {UndoManager} undoManager
* @function * @param {Array<StackItem>} stack
* @param {string} eventType
* @return {StackItem?}
*/ */
function applyReverseOperation (y, scope, reverseBuffer) { const popStackItem = (undoManager, stack, eventType) => {
let performedUndo = false /**
let undoOp = null * Whether a change happened
transact(y, () => { * @type {StackItem?}
while (!performedUndo && reverseBuffer.length > 0) { */
undoOp = reverseBuffer.pop() let result = null
// make sure that it is possible to iterate {from}-{to} const doc = undoManager.doc
if (undoOp.fromState !== null) { const type = undoManager.type
y.os.getItemCleanStart(undoOp.fromState) transact(doc, transaction => {
y.os.getItemCleanEnd(undoOp.toState) while (stack.length > 0 && result === null) {
y.os.iterate(undoOp.fromState, undoOp.toState, op => { const store = doc.store
while (op._deleted && op._redone !== null) { const stackItem = /** @type {StackItem} */ (stack.pop())
op = op._redone const itemsToRedo = new Set()
} let performedChange = false
if (op._deleted === false && isParentOf(scope, op)) { iterateDeletedStructs(transaction, stackItem.ds, store, struct => {
performedUndo = true if (struct instanceof Item && isParentOf(type, struct)) {
op._delete(y) itemsToRedo.add(struct)
} }
}) })
} itemsToRedo.forEach(item => {
const redoitems = new Set() performedChange = redoItem(transaction, item, itemsToRedo) !== null || performedChange
for (let del of undoOp.deletedStructs) { })
const fromState = del.from const structs = /** @type {Array<GC|Item>} */ (store.clients.get(doc.clientID))
const toState = createID(fromState.user, fromState.clock + del.len - 1) iterateStructs(transaction, structs, stackItem.start, stackItem.len, struct => {
y.os.getItemCleanStart(fromState) if (!struct.deleted && isParentOf(type, /** @type {Item} */ (struct))) {
y.os.getItemCleanEnd(toState) struct.delete(transaction)
y.os.iterate(fromState, toState, op => { performedChange = true
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)
} }
}) })
result = stackItem
} }
redoitems.forEach(op => { }, undoManager)
const opUndone = op._redo(y, redoitems) if (result != null) {
performedUndo = performedUndo || opUndone undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
})
} }
}) return result
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 * Fires 'stack-item-added' event when a stack item was added to either the undo- or
* undoing and redoing of locally created changes. * 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`.
* *
* @private * @extends {Observable<'stack-item-added'|'stack-item-popped'>}
* @function
*/ */
export class UndoManager { export class UndoManager extends Observable {
/** /**
* @param {YType} scope The scope on which to listen for changes. * @param {AbstractType<any>} type
* @param {Object} options Optionally provided configuration. * @param {Set<any>} [trackedTransactionOrigins=new Set([null])]
* @param {object} [options={captureTimeout=500}]
*/ */
constructor (scope, options = {}) { constructor (type, trackedTransactionOrigins = new Set([null]), { captureTimeout = 500 } = {}) {
this.options = options super()
this._bindings = new Set(options.bindings) this.type = type
options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout trackedTransactionOrigins.add(this)
this._undoBuffer = [] this.trackedTransactionOrigins = trackedTransactionOrigins
this._redoBuffer = [] /**
this._scope = scope * @type {Array<StackItem>}
this._undoing = false */
this._redoing = false this.undoStack = []
this._lastTransactionWasUndo = false /**
const doc = scope.doc * @type {Array<StackItem>}
this.y = doc */
let bindingInfos this.redoStack = []
doc.on('beforeTransaction', (y, transaction, remote) => { /**
if (!remote) { * Whether the client is currently undoing (calling UndoManager.undo)
// Store binding information before transaction is executed *
// By restoring the binding information, we can make sure that the state * @type {boolean}
// before the transaction can be recovered */
bindingInfos = new Map() this.undoing = false
this._bindings.forEach(binding => { this.redoing = false
bindingInfos.set(binding, binding._getUndoStackInfo()) 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)
} }
}) })
doc.on('afterTransaction', (y, transaction, remote) => { this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this])
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)
}
}
}) })
} }
/** /**
* 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 () { stopCapturing () {
this._lastTransactionWasUndo = true this.lastChange = 0
} }
/** /**
* Undo the last locally created change. * Undo last changes on type.
* *
* @private * @return {StackItem?} Returns StackItem if a change was applied
* @function
*/ */
undo () { undo () {
this._undoing = true this.undoing = true
const performedUndo = applyReverseOperation(this.y, this._scope, this._undoBuffer) let res
this._undoing = false try {
return performedUndo res = popStackItem(this, this.undoStack, 'undo')
} finally {
this.undoing = false
}
return res
} }
/** /**
* Redo the last locally created change. * Redo last undo operation.
* *
* @private * @return {StackItem?} Returns StackItem if a change was applied
* @function
*/ */
redo () { redo () {
this._redoing = true this.redoing = true
const performedRedo = applyReverseOperation(this.y, this._scope, this._redoBuffer) let res
this._redoing = false try {
return performedRedo res = popStackItem(this, this.redoStack, 'redo')
} finally {
this.redoing = false
}
return res
} }
} }

View File

@ -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`. * Check if `parent` is a parent of `child`.
* *
* @param {AbstractType<any>} parent * @param {AbstractType<any>} parent
* @param {AbstractType<any>} child * @param {Item|null} child
* @return {Boolean} Whether `parent` is a parent of `child`. * @return {Boolean} Whether `parent` is a parent of `child`.
* *
* @private * @private
* @function * @function
*/ */
export const isParentOf = (parent, child) => { export const isParentOf = (parent, child) => {
while (child._item !== null) { while (child !== null) {
if (child === parent) { if (child.parent === parent) {
return true return true
} }
child = child._item.parent child = child.parent._item
} }
return false return false
} }

View File

@ -4,6 +4,7 @@ import * as map from './y-map.tests.js'
import * as text from './y-text.tests.js' import * as text from './y-text.tests.js'
import * as xml from './y-xml.tests.js' import * as xml from './y-xml.tests.js'
import * as encoding from './encoding.tests.js' import * as encoding from './encoding.tests.js'
import * as undoredo from './undo-redo.tests.js'
import { runTests } from 'lib0/testing.js' import { runTests } from 'lib0/testing.js'
import { isBrowser, isNode } from 'lib0/environment.js' import { isBrowser, isNode } from 'lib0/environment.js'
@ -13,7 +14,7 @@ if (isBrowser) {
log.createVConsole(document.body) log.createVConsole(document.body)
} }
runTests({ runTests({
map, array, text, xml, encoding map, array, text, xml, encoding, undoredo
}).then(success => { }).then(success => {
/* istanbul ignore next */ /* istanbul ignore next */
if (isNode) { if (isNode) {

View File

@ -266,8 +266,11 @@ export const compare = users => {
t.assert(u.store.pendingClientsStructRefs.size === 0) t.assert(u.store.pendingClientsStructRefs.size === 0)
} }
// Test Array iterator // 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 // 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<string,any>} * @type {Object<string,any>}
*/ */

182
tests/undo-redo.tests.js Normal file
View File

@ -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() === '<undefined><p>content</p></undefined>')
// format textchild and revert that change
undoManager.stopCapturing()
textchild.format(3, 4, { bold: {} })
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
undoManager.undo()
t.assert(xml0.toString() === '<undefined><p>content</p></undefined>')
undoManager.redo()
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
xml0.delete(0, 1)
t.assert(xml0.toString() === '<undefined></undefined>')
undoManager.undo()
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
}
/**
* @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() === '')
}