Compare commits

...

6 Commits

Author SHA1 Message Date
Kevin Jahns
dd2b8bc6c7 13.0.0-94 2019-06-25 11:57:50 +02:00
Kevin Jahns
463065ac21 UndoManager: keep item before item is deleted (fixes some edge cases of followRedo) 2019-06-25 11:56:41 +02:00
Kevin Jahns
d064e6e96e UndoManager accepts an array of types as scope. Implements #156 2019-06-25 02:26:18 +02:00
Kevin Jahns
b1ed2df208 proper TOC links 2019-06-25 00:10:12 +02:00
Kevin Jahns
1fe4ef135c 13.0.0-93 2019-06-24 23:06:11 +02:00
Kevin Jahns
e376b5d472 UndoManager fixes 2019-06-24 23:04:53 +02:00
8 changed files with 120 additions and 74 deletions

View File

@@ -25,10 +25,10 @@ suited for even large documents.
* [Getting Started](#Getting-Started) * [Getting Started](#Getting-Started)
* [API](#API) * [API](#API)
* [Shared Types](#Shared-Types) * [Shared Types](#Shared-Types)
* [Y.Doc](#Y.Doc) * [Y.Doc](#YDoc)
* [Document Updates](#Document-Updates) * [Document Updates](#Document-Updates)
* [Relative Positions](#Relative-Positions) * [Relative Positions](#Relative-Positions)
* [Y.UndoManager](#Y.UndoManager) * [Y.UndoManager](#YUndoManager)
* [Miscellaneous](#Miscellaneous) * [Miscellaneous](#Miscellaneous)
* [Typescript Declarations](#Typescript-Declarations) * [Typescript Declarations](#Typescript-Declarations)
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm) * [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
@@ -661,9 +661,9 @@ ytext.toString() // => 'abc'
``` ```
<dl> <dl>
<b><code>constructor(type:Y.AbstractType, <b><code>constructor(scope:Y.AbstractType|Array&lt;Y.AbstractType&gt;,
[trackedTransactionOrigins:Set&lt;any&gt;, [{captureTimeout: number}]])</code></b> [trackedTransactionOrigins:Set&lt;any&gt;, [{captureTimeout: number}]])</code></b>
<dd></dd> <dd>Accepts either single type as scope or an array of types.</dd>
<b><code>undo()</code></b> <b><code>undo()</code></b>
<dd></dd> <dd></dd>
<b><code>redo()</code></b> <b><code>redo()</code></b>

43
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "yjs", "name": "yjs",
"version": "13.0.0-92", "version": "13.0.0-94",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -1490,8 +1490,7 @@
"ansi-regex": { "ansi-regex": {
"version": "2.1.1", "version": "2.1.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
@@ -1512,14 +1511,12 @@
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@@ -1534,20 +1531,17 @@
"code-point-at": { "code-point-at": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"core-util-is": { "core-util-is": {
"version": "1.0.2", "version": "1.0.2",
@@ -1664,8 +1658,7 @@
"inherits": { "inherits": {
"version": "2.0.3", "version": "2.0.3",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"ini": { "ini": {
"version": "1.3.5", "version": "1.3.5",
@@ -1677,7 +1670,6 @@
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"number-is-nan": "^1.0.0" "number-is-nan": "^1.0.0"
} }
@@ -1692,7 +1684,6 @@
"version": "3.0.4", "version": "3.0.4",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }
@@ -1700,14 +1691,12 @@
"minimist": { "minimist": {
"version": "0.0.8", "version": "0.0.8",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"minipass": { "minipass": {
"version": "2.3.5", "version": "2.3.5",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"safe-buffer": "^5.1.2", "safe-buffer": "^5.1.2",
"yallist": "^3.0.0" "yallist": "^3.0.0"
@@ -1726,7 +1715,6 @@
"version": "0.5.1", "version": "0.5.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"minimist": "0.0.8" "minimist": "0.0.8"
} }
@@ -1807,8 +1795,7 @@
"number-is-nan": { "number-is-nan": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
@@ -1820,7 +1807,6 @@
"version": "1.4.0", "version": "1.4.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"wrappy": "1" "wrappy": "1"
} }
@@ -1906,8 +1892,7 @@
"safe-buffer": { "safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"safer-buffer": { "safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
@@ -1943,7 +1928,6 @@
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"code-point-at": "^1.0.0", "code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0", "is-fullwidth-code-point": "^1.0.0",
@@ -1963,7 +1947,6 @@
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"ansi-regex": "^2.0.0" "ansi-regex": "^2.0.0"
} }
@@ -2007,14 +1990,12 @@
"wrappy": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"yallist": { "yallist": {
"version": "3.0.3", "version": "3.0.3",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
} }
} }
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "yjs", "name": "yjs",
"version": "13.0.0-92", "version": "13.0.0-94",
"description": "Shared Editing Library", "description": "Shared Editing Library",
"main": "./dist/yjs.js", "main": "./dist/yjs.js",
"module": "./dist/yjs.mjs", "module": "./dist/yjs.mjs",

View File

@@ -42,5 +42,6 @@ export {
iterateDeletedStructs, iterateDeletedStructs,
applyUpdate, applyUpdate,
encodeStateAsUpdate, encodeStateAsUpdate,
encodeStateVector encodeStateVector,
UndoManager
} from './internals.js' } from './internals.js'

View File

@@ -33,6 +33,31 @@ 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'
/**
* @param {StructStore} store
* @param {ID} id
* @return {{item:Item, diff:number}}
*/
export const followRedone = (store, id) => {
/**
* @type {ID|null}
*/
let nextID = id
let diff = 0
let item
do {
if (diff > 0) {
nextID = createID(nextID.client, nextID.clock + diff)
}
item = getItem(store, nextID)
diff = nextID.clock - item.id.clock
nextID = item.redone
} while (nextID !== null)
return {
item, diff
}
}
/** /**
* Make sure that neither item nor any of its parents is ever deleted. * Make sure that neither item nor any of its parents is ever deleted.
* *
@@ -77,6 +102,9 @@ export const splitItem = (transaction, leftItem, diff) => {
if (leftItem.keep) { if (leftItem.keep) {
rightItem.keep = true rightItem.keep = true
} }
if (leftItem.redone !== null) {
rightItem.redone = createID(leftItem.redone.client, leftItem.redone.clock + diff)
}
// 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
@@ -106,7 +134,7 @@ export const splitItem = (transaction, leftItem, diff) => {
*/ */
export const redoItem = (transaction, item, redoitems) => { export const redoItem = (transaction, item, redoitems) => {
if (item.redone !== null) { if (item.redone !== null) {
return item.redone return getItemCleanStart(transaction, transaction.doc.store, item.redone)
} }
let parentItem = item.parent._item let parentItem = item.parent._item
/** /**
@@ -146,7 +174,7 @@ export const redoItem = (transaction, item, redoitems) => {
} }
if (parentItem !== null && parentItem.redone !== null) { if (parentItem !== null && parentItem.redone !== null) {
while (parentItem.redone !== null) { while (parentItem.redone !== null) {
parentItem = parentItem.redone parentItem = getItemCleanStart(transaction, transaction.doc.store, parentItem.redone)
} }
// find next cloned_redo items // find next cloned_redo items
while (left !== null) { while (left !== null) {
@@ -156,7 +184,7 @@ export const redoItem = (transaction, item, redoitems) => {
let leftTrace = left let leftTrace = left
// trace redone until parent matches // trace redone until parent matches
while (leftTrace !== null && leftTrace.parent._item !== parentItem) { while (leftTrace !== null && leftTrace.parent._item !== parentItem) {
leftTrace = leftTrace.redone leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, transaction.doc.store, leftTrace.redone)
} }
if (leftTrace !== null && leftTrace.parent._item === parentItem) { if (leftTrace !== null && leftTrace.parent._item === parentItem) {
left = leftTrace left = leftTrace
@@ -171,7 +199,7 @@ export const redoItem = (transaction, item, redoitems) => {
let rightTrace = right let rightTrace = right
// trace redone until parent matches // trace redone until parent matches
while (rightTrace !== null && rightTrace.parent._item !== parentItem) { while (rightTrace !== null && rightTrace.parent._item !== parentItem) {
rightTrace = rightTrace.redone rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, transaction.doc.store, rightTrace.redone)
} }
if (rightTrace !== null && rightTrace.parent._item === parentItem) { if (rightTrace !== null && rightTrace.parent._item === parentItem) {
right = rightTrace right = rightTrace
@@ -188,7 +216,8 @@ export const redoItem = (transaction, item, redoitems) => {
item.parentSub, item.parentSub,
item.content.copy() item.content.copy()
) )
item.redone = redoneItem item.redone = redoneItem.id
keepItem(redoneItem)
redoneItem.integrate(transaction) redoneItem.integrate(transaction)
return redoneItem return redoneItem
} }
@@ -254,7 +283,7 @@ export class Item extends AbstractStruct {
/** /**
* If this type's effect is reundone this type refers to the type that undid * If this type's effect is reundone this type refers to the type that undid
* this operation. * this operation.
* @type {Item | null} * @type {ID | null}
*/ */
this.redone = null this.redone = null
/** /**

View File

@@ -1,6 +1,5 @@
import { import {
getItem,
createID, createID,
writeID, writeID,
readID, readID,
@@ -9,6 +8,7 @@ import {
findRootTypeKey, findRootTypeKey,
Item, Item,
ContentType, ContentType,
followRedone,
ID, Doc, AbstractType // eslint-disable-line ID, Doc, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@@ -222,19 +222,22 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
if (getState(store, rightID.client) <= rightID.clock) { if (getState(store, rightID.client) <= rightID.clock) {
return null return null
} }
const right = getItem(store, rightID) const res = followRedone(store, rightID)
const right = res.item
if (!(right instanceof Item)) { if (!(right instanceof Item)) {
return null return null
} }
index = right.deleted || !right.countable ? 0 : rightID.clock - right.id.clock
let n = right.left
while (n !== null) {
if (!n.deleted && n.countable) {
index += n.length
}
n = n.left
}
type = right.parent type = right.parent
if (type._item !== null && !type._item.deleted) {
index = right.deleted || !right.countable ? 0 : res.diff
let n = right.left
while (n !== null) {
if (!n.deleted && n.countable) {
index += n.length
}
n = n.left
}
}
} else { } else {
if (tname !== null) { if (tname !== null) {
type = doc.get(tname) type = doc.get(tname)
@@ -243,9 +246,9 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
// type does not exist yet // type does not exist yet
return null return null
} }
const struct = getItem(store, typeID) const { item } = followRedone(store, typeID)
if (struct instanceof Item && struct.content instanceof ContentType) { if (item instanceof Item && item.content instanceof ContentType) {
type = struct.content.type type = item.content.type
} else { } else {
// struct is garbage collected // struct is garbage collected
return null return null
@@ -255,9 +258,6 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
} }
index = type._length index = type._length
} }
if (type._item !== null && type._item.deleted) {
return null
}
return createAbsolutePosition(type, index) return createAbsolutePosition(type, index)
} }

View File

@@ -6,11 +6,14 @@ import {
redoItem, redoItem,
iterateStructs, iterateStructs,
isParentOf, isParentOf,
Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line createID,
followRedone,
getItemCleanStart,
Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as time from 'lib0/time.js' import * as time from 'lib0/time.js'
import { Observable } from 'lib0/observable' import { Observable } from 'lib0/observable.js'
class StackItem { class StackItem {
/** /**
@@ -42,7 +45,7 @@ const popStackItem = (undoManager, stack, eventType) => {
*/ */
let result = null let result = null
const doc = undoManager.doc const doc = undoManager.doc
const type = undoManager.type const scope = undoManager.scope
transact(doc, transaction => { transact(doc, transaction => {
while (stack.length > 0 && result === null) { while (stack.length > 0 && result === null) {
const store = doc.store const store = doc.store
@@ -50,7 +53,7 @@ const popStackItem = (undoManager, stack, eventType) => {
const itemsToRedo = new Set() const itemsToRedo = new Set()
let performedChange = false let performedChange = false
iterateDeletedStructs(transaction, stackItem.ds, store, struct => { iterateDeletedStructs(transaction, stackItem.ds, store, struct => {
if (struct instanceof Item && isParentOf(type, struct)) { if (struct instanceof Item && scope.some(type => isParentOf(type, struct))) {
itemsToRedo.add(struct) itemsToRedo.add(struct)
} }
}) })
@@ -59,17 +62,28 @@ const popStackItem = (undoManager, stack, eventType) => {
}) })
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(doc.clientID)) const structs = /** @type {Array<GC|Item>} */ (store.clients.get(doc.clientID))
iterateStructs(transaction, structs, stackItem.start, stackItem.len, struct => { iterateStructs(transaction, structs, stackItem.start, stackItem.len, struct => {
if (!struct.deleted && isParentOf(type, /** @type {Item} */ (struct))) { if (struct instanceof Item && !struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, store, createID(item.id.client, item.id.clock + diff))
}
if (item.length > stackItem.len) {
getItemCleanStart(transaction, store, createID(item.id.client, item.id.clock + stackItem.len))
}
struct = item
}
keepItem(struct)
struct.delete(transaction) struct.delete(transaction)
performedChange = true performedChange = true
} }
}) })
result = stackItem result = stackItem
if (result != null) {
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
}
} }
}, undoManager) }, undoManager)
if (result != null) {
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
}
return result return result
} }
@@ -84,13 +98,13 @@ const popStackItem = (undoManager, stack, eventType) => {
*/ */
export class UndoManager extends Observable { export class UndoManager extends Observable {
/** /**
* @param {AbstractType<any>} type * @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
* @param {Set<any>} [trackedTransactionOrigins=new Set([null])] * @param {Set<any>} [trackedTransactionOrigins=new Set([null])]
* @param {object} [options={captureTimeout=500}] * @param {object} [options={captureTimeout=500}]
*/ */
constructor (type, trackedTransactionOrigins = new Set([null]), { captureTimeout = 500 } = {}) { constructor (typeScope, trackedTransactionOrigins = new Set([null]), { captureTimeout = 500 } = {}) {
super() super()
this.type = type this.scope = typeScope instanceof Array ? typeScope : [typeScope]
trackedTransactionOrigins.add(this) trackedTransactionOrigins.add(this)
this.trackedTransactionOrigins = trackedTransactionOrigins this.trackedTransactionOrigins = trackedTransactionOrigins
/** /**
@@ -108,11 +122,11 @@ export class UndoManager extends Observable {
*/ */
this.undoing = false this.undoing = false
this.redoing = false this.redoing = false
this.doc = /** @type {Doc} */ (type.doc) this.doc = /** @type {Doc} */ (this.scope[0].doc)
this.lastChange = 0 this.lastChange = 0
type.observeDeep((events, transaction) => { this.doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
// Only track certain transactions // Only track certain transactions
if (!this.trackedTransactionOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedTransactionOrigins.has(transaction.origin.constructor))) { if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedTransactionOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedTransactionOrigins.has(transaction.origin.constructor)))) {
return return
} }
const undoing = this.undoing const undoing = this.undoing
@@ -141,7 +155,7 @@ export class UndoManager extends Observable {
} }
// make sure that deleted structs are not gc'd // make sure that deleted structs are not gc'd
iterateDeletedStructs(transaction, transaction.deleteSet, transaction.doc.store, /** @param {Item|GC} item */ item => { iterateDeletedStructs(transaction, transaction.deleteSet, transaction.doc.store, /** @param {Item|GC} item */ item => {
if (item instanceof Item && isParentOf(type, item)) { if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item) keepItem(item)
} }
}) })

View File

@@ -180,3 +180,24 @@ export const testTrackClass = tc => {
undoManager.undo() undoManager.undo()
t.assert(text0.toString() === '') t.assert(text0.toString() === '')
} }
/**
* @param {t.TestCase} tc
*/
export const testTypeScope = tc => {
const { array0 } = init(tc, { users: 3 })
// only track origins that are numbers
const text0 = new Y.Text()
const text1 = new Y.Text()
array0.insert(0, [text0, text1])
const undoManager = new UndoManager(text0)
const undoManagerBoth = new UndoManager([text0, text1])
text1.insert(0, 'abc')
t.assert(undoManager.undoStack.length === 0)
t.assert(undoManagerBoth.undoStack.length === 1)
t.assert(text1.toString() === 'abc')
undoManager.undo()
t.assert(text1.toString() === 'abc')
undoManagerBoth.undo()
t.assert(text1.toString() === '')
}