Port Undo/Redo approach with a clean API
This commit is contained in:
parent
14df5b72af
commit
03458dc641
139
README.v13.md
139
README.v13.md
@ -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<M></code></b>
|
<b><code>map(function(T, number, YArray):M):Array<M></code></b>
|
||||||
<dd></dd>
|
<dd></dd>
|
||||||
<b><code>toArray():Array<object|boolean|Array|string|number|Uint8Array|Y.Type></code></b>
|
<b><code>toArray():Array<object|boolean|Array|string|number|Uint8Array|Y.Type></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<string, Object|boolean|Array|string|number></code></b>
|
<b><code>toJSON():Object<string, Object|boolean|Array|string|number|Uint8Array></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<any>, [{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<any,any> }, 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<any,any> }, 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
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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>}
|
||||||
*/
|
*/
|
||||||
|
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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
|
||||||
|
@ -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>>}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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'
|
||||||
* @private
|
import { Observable } from 'lib0/observable'
|
||||||
*/
|
|
||||||
class ReverseOperation {
|
class StackItem {
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
/**
|
/**
|
||||||
* Maps from binding to binding information (e.g. cursor information)
|
* @param {DeleteSet} ds
|
||||||
|
* @param {number} start clock start of the local client
|
||||||
|
* @param {number} len
|
||||||
*/
|
*/
|
||||||
this.bindingInfos = bindingInfos
|
constructor (ds, start, len) {
|
||||||
|
this.ds = ds
|
||||||
|
this.start = start
|
||||||
|
this.len = len
|
||||||
|
/**
|
||||||
|
* Use this to save and restore metadata like selection range
|
||||||
|
*/
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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
182
tests/undo-redo.tests.js
Normal 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() === '')
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user