Compare commits

...

16 Commits

Author SHA1 Message Date
Kevin Jahns
772bb87d5c 13.0.8 2020-05-13 19:29:51 +02:00
Kevin Jahns
dab172fa1d Rework UndoManager to support changes from other / multiple users 2020-05-13 19:28:30 +02:00
Kevin Jahns
a70c5112cd fix wrong type declaration in documentation. fixes #195 2020-05-11 11:10:38 +02:00
Kevin Jahns
7cb423c046 13.0.7 2020-05-11 01:46:51 +02:00
Kevin Jahns
4547b35641 cleanup formatting attributes 2020-05-11 01:45:27 +02:00
Kevin Jahns
4c87f9a021 13.0.6 2020-05-08 14:50:53 +02:00
Kevin Jahns
4b08c67e06 bump lib0 to fix critical encoding issue in safari 2020-05-08 14:49:50 +02:00
Kevin Jahns
9f5bc9ddfe change client id when duplicate content is detected 2020-05-03 16:10:58 +02:00
Kevin Jahns
b399ffa765 add gc information to API docs 2020-04-26 13:24:18 +02:00
Kevin Jahns
180f4667c1 Readme correction: UndoManager accepts options 2020-04-17 02:02:09 +02:00
Kevin Jahns
9455373611 Merge branch 'master' of github.com:yjs/yjs 2020-04-15 20:50:29 +02:00
Kevin Jahns
aa804d89c0 update now.sh links 2020-04-15 19:52:34 +02:00
Kevin Jahns
3ef51a5d1a run test-exhaustive 2020-04-03 12:11:25 +02:00
Kevin Jahns
e61089c659 npm ci before workflow start 2020-04-03 12:09:13 +02:00
Kevin Jahns
97625cf29b fix workflow 2020-04-03 12:05:43 +02:00
Kevin Jahns
a5dc6c27aa Setup github workflow 2020-04-03 12:02:37 +02:00
13 changed files with 510 additions and 70 deletions

31
.github/workflows/nodejs.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [10.x, 12.x, 13.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run lint
- run: npm run test-extensive
env:
CI: true

View File

@@ -52,13 +52,10 @@ are implemented in separate modules.
| Name | Cursors | Binding | Demo | | Name | Cursors | Binding | Demo |
|---|:-:|---|---| |---|:-:|---|---|
| [ProseMirror](https://prosemirror.net/)                                                   | ✔ | [y-prosemirror](http://github.com/yjs/y-prosemirror) | [demo](https://yjs-demos.now.sh/prosemirror/) | | [ProseMirror](https://prosemirror.net/)                                                   | ✔ | [y-prosemirror](http://github.com/yjs/y-prosemirror) | [demo](https://demos.yjs.dev/prosemirror/prosemirror.html) |
| [Quill](https://quilljs.com/) | ✔ | [y-quill](http://github.com/yjs/y-quill) | [demo](https://yjs-demos.now.sh/quill/) | | [Quill](https://quilljs.com/) | ✔ | [y-quill](http://github.com/yjs/y-quill) | [demo](https://demos.yjs.dev/quill/quill.html) |
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/yjs/y-codemirror) | [demo](https://yjs-demos.now.sh/codemirror/) | | [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/yjs/y-codemirror) | [demo](https://demos.yjs.dev/codemirror/codemirror.html) |
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](http://github.com/yjs/y-monaco) | [demo](https://yjs-demos.now.sh/monaco/) | | [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](http://github.com/yjs/y-monaco) | [demo](https://demos.yjs.dev/monaco/monaco.html) |
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/yjs/y-ace) | [demo](https://yjs-demos.now.sh/ace/) |
| [Textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) | | [y-textarea](http://github.com/yjs/y-textarea) | [demo](https://yjs-demos.now.sh/textarea/) |
| [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) | | [y-dom](http://github.com/yjs/y-dom) | [demo](https://yjs-demos.now.sh/dom/) |
### Providers ### Providers
@@ -466,6 +463,12 @@ const doc = new Y.Doc()
<dl> <dl>
<b><code>clientID</code></b> <b><code>clientID</code></b>
<dd>A unique id that identifies this client. (readonly)</dd> <dd>A unique id that identifies this client. (readonly)</dd>
<b><code>gc</code></b>
<dd>
Whether garbage collection is enabled on this doc instance. Set `doc.gc = false`
in order to disable gc and be able to restore old content. See https://github.com/yjs/yjs#yjs-crdt-algorithm
for more information about gc in Yjs.
</dd>
<b><code>transact(function(Transaction):void [, origin:any])</code></b> <b><code>transact(function(Transaction):void [, origin:any])</code></b>
<dd> <dd>
Every change on the shared document happens in a transaction. Observer calls and Every change on the shared document happens in a transaction. Observer calls and
@@ -642,7 +645,7 @@ 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. Yjs type. The changes can be optionally scoped to transaction origins.
```js ```js
const ytext = doc.getArray('array') const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext) const undoManager = new Y.UndoManager(ytext)
ytext.insert(0, 'abc') ytext.insert(0, 'abc')
@@ -653,8 +656,8 @@ ytext.toString() // => 'abc'
``` ```
<dl> <dl>
<b><code>constructor(scope:Y.AbstractType|Array&lt;Y.AbstractType&gt;, <b><code>constructor(scope:Y.AbstractType|Array&lt;Y.AbstractType&gt;
[[{captureTimeout:number,trackedOrigins:Set&lt;any&gt;,deleteFilter:function(item):boolean}]])</code></b> [, {captureTimeout:number,trackedOrigins:Set&lt;any&gt;,deleteFilter:function(item):boolean}])</code></b>
<dd>Accepts either single type as scope or an array of types.</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>
@@ -694,28 +697,30 @@ StackItem won't be merged.
// without stopCapturing // without stopCapturing
ytext.insert(0, 'a') ytext.insert(0, 'a')
ytext.insert(1, 'b') ytext.insert(1, 'b')
um.undo() undoManager.undo()
ytext.toString() // => '' (note that 'ab' was removed) ytext.toString() // => '' (note that 'ab' was removed)
// with stopCapturing // with stopCapturing
ytext.insert(0, 'a') ytext.insert(0, 'a')
um.stopCapturing() undoManager.stopCapturing()
ytext.insert(0, 'b') ytext.insert(0, 'b')
um.undo() undoManager.undo()
ytext.toString() // => 'a' (note that only 'b' was removed) ytext.toString() // => 'a' (note that only 'b' was removed)
``` ```
#### Example: Specify tracked origins #### Example: Specify tracked origins
Every change on the shared document has an origin. If no origin was specified, Every change on the shared document has an origin. If no origin was specified,
it defaults to `null`. By specifying `trackedTransactionOrigins` you can it defaults to `null`. By specifying `trackedOrigins` you can
selectively specify which changes should be tracked by `UndoManager`. The selectively specify which changes should be tracked by `UndoManager`. The
UndoManager instance is always added to `trackedTransactionOrigins`. UndoManager instance is always added to `trackedOrigins`.
```js ```js
class CustomBinding {} class CustomBinding {}
const ytext = doc.getArray('array') const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding])) const undoManager = new Y.UndoManager(ytext, {
trackedOrigins: new Set([42, CustomBinding])
})
ytext.insert(0, 'abc') ytext.insert(0, 'abc')
undoManager.undo() undoManager.undo()
@@ -752,8 +757,10 @@ additional meta information like the cursor location or the view on the
document. You can assign meta-information to Undo-/Redo-StackItems. document. You can assign meta-information to Undo-/Redo-StackItems.
```js ```js
const ytext = doc.getArray('array') const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding])) const undoManager = new Y.UndoManager(ytext, {
trackedOrigins: new Set([42, CustomBinding])
})
undoManager.on('stack-item-added', event => { undoManager.on('stack-item-added', event => {
// save the current cursor location on the stack-item // save the current cursor location on the stack-item

8
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "yjs", "name": "yjs",
"version": "13.0.5", "version": "13.0.8",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -1439,9 +1439,9 @@
} }
}, },
"lib0": { "lib0": {
"version": "0.2.22", "version": "0.2.26",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.22.tgz", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.26.tgz",
"integrity": "sha512-SFzX7/SGgrOK6VABQugczhAwaaZLW1VcrE9xG+cVG1+AMQWmcu/7SZaJq0KORnfHr1xK4P6JUBWfoxSCwBcgLA==", "integrity": "sha512-DTf0VmFNi/eT+3Q+6rNHYdIAx69ROpvQkpnplpDoErW8NeRwjPwoIKjCF3rKebsMrQoxH4tFD1bvMQb4CUzcFg==",
"requires": { "requires": {
"isomorphic.js": "^0.1.3" "isomorphic.js": "^0.1.3"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "yjs", "name": "yjs",
"version": "13.0.5", "version": "13.0.8",
"description": "Shared Editing Library", "description": "Shared Editing Library",
"main": "./dist/yjs.cjs", "main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs", "module": "./dist/yjs.mjs",
@@ -56,7 +56,7 @@
}, },
"homepage": "https://yjs.dev", "homepage": "https://yjs.dev",
"dependencies": { "dependencies": {
"lib0": "^0.2.22" "lib0": "^0.2.26"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^11.0.1", "@rollup/plugin-commonjs": "^11.0.1",

View File

@@ -26,6 +26,7 @@ export {
ContentType, ContentType,
AbstractType, AbstractType,
RelativePosition, RelativePosition,
getTypeChildren,
createRelativePositionFromTypeIndex, createRelativePositionFromTypeIndex,
createRelativePositionFromJSON, createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition, createAbsolutePositionFromRelativePosition,
@@ -47,6 +48,7 @@ export {
typeMapGetSnapshot, typeMapGetSnapshot,
iterateDeletedStructs, iterateDeletedStructs,
applyUpdate, applyUpdate,
readUpdate,
encodeStateAsUpdate, encodeStateAsUpdate,
encodeStateVector, encodeStateVector,
UndoManager, UndoManager,

View File

@@ -19,6 +19,22 @@ import * as iterator from 'lib0/iterator.js'
import * as error from 'lib0/error.js' import * as error from 'lib0/error.js'
import * as encoding from 'lib0/encoding.js' // eslint-disable-line import * as encoding from 'lib0/encoding.js' // eslint-disable-line
/**
* Accumulate all (list) children of a type and return them as an Array.
*
* @param {AbstractType<any>} t
* @return {Array<Item>}
*/
export const getTypeChildren = t => {
let s = t._start
const arr = []
while (s) {
arr.push(s)
s = s.right
}
return arr
}
/** /**
* Call event listeners with an event. This will also add an event to all * Call event listeners with an event. This will also add an event to all
* parents (for `.observeDeep` handlers). * parents (for `.observeDeep` handlers).

View File

@@ -14,15 +14,19 @@ import {
callTypeObservers, callTypeObservers,
transact, transact,
ContentEmbed, ContentEmbed,
GC,
ContentFormat, ContentFormat,
ContentString, ContentString,
splitSnapshotAffectedStructs, splitSnapshotAffectedStructs,
iterateDeletedStructs,
iterateStructs,
ID, Doc, Item, Snapshot, Transaction // eslint-disable-line ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as encoding from 'lib0/encoding.js' import * as encoding from 'lib0/encoding.js'
import * as object from 'lib0/object.js' import * as object from 'lib0/object.js'
import * as map from 'lib0/map.js'
/** /**
* @param {any} a * @param {any} a
@@ -320,6 +324,110 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes) return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes)
} }
/**
* Call this function after string content has been deleted in order to
* clean up formatting Items.
*
* @param {Transaction} transaction
* @param {Item} start
* @param {Item|null} end exclusive end, automatically iterates to the next Content Item
* @param {Map<string,any>} startAttributes
* @param {Map<string,any>} endAttributes This attribute is modified!
* @return {number} The amount of formatting Items deleted.
*
* @function
*/
const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttributes) => {
while (end && end.content.constructor !== ContentString && end.content.constructor !== ContentEmbed) {
if (!end.deleted && end.content.constructor === ContentFormat) {
updateCurrentAttributes(endAttributes, /** @type {ContentFormat} */ (end.content))
}
end = end.right
}
let cleanups = 0
while (start !== end) {
if (!start.deleted) {
const content = start.content
switch (content.constructor) {
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (content)
if ((endAttributes.get(key) || null) !== value || (startAttributes.get(key) || null) === value) {
// Either this format is overwritten or it is not necessary because the attribute already existed.
start.delete(transaction)
cleanups++
}
break
}
}
}
start = /** @type {Item} */ (start.right)
}
return cleanups
}
/**
* @param {Transaction} transaction
* @param {Item | null} item
*/
const cleanupContextlessFormattingGap = (transaction, item) => {
// iterate until item.right is null or content
while (item && item.right && (item.right.deleted || (item.right.content.constructor !== ContentString && item.right.content.constructor !== ContentEmbed))) {
item = item.right
}
const attrs = new Set()
// iterate back until a content item is found
while (item && (item.deleted || (item.content.constructor !== ContentString && item.content.constructor !== ContentEmbed))) {
if (!item.deleted && item.content.constructor === ContentFormat) {
const key = /** @type {ContentFormat} */ (item.content).key
if (attrs.has(key)) {
item.delete(transaction)
} else {
attrs.add(key)
}
}
item = item.left
}
}
/**
* This function is experimental and subject to change / be removed.
*
* Ideally, we don't need this function at all. Formatting attributes should be cleaned up
* automatically after each change. This function iterates twice over the complete YText type
* and removes unnecessary formatting attributes. This is also helpful for testing.
*
* This function won't be exported anymore as soon as there is confidence that the YText type works as intended.
*
* @param {YText} type
* @return {number} How many formatting attributes have been cleaned up.
*/
export const cleanupYTextFormatting = type => {
let res = 0
transact(/** @type {Doc} */ (type.doc), transaction => {
let start = /** @type {Item} */ (type._start)
let end = type._start
let startAttributes = map.create()
const currentAttributes = map.copy(startAttributes)
while (end) {
if (end.deleted === false) {
switch (end.content.constructor) {
case ContentFormat:
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (end.content))
break
case ContentEmbed:
case ContentString:
res += cleanupFormattingGap(transaction, start, end, startAttributes, currentAttributes)
startAttributes = map.copy(currentAttributes)
start = end
break
}
}
end = end.right
}
})
return res
}
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {Item|null} left * @param {Item|null} left
@@ -332,6 +440,8 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
* @function * @function
*/ */
const deleteText = (transaction, left, right, currentAttributes, length) => { const deleteText = (transaction, left, right, currentAttributes, length) => {
const startAttrs = map.copy(currentAttributes)
const start = right
while (length > 0 && right !== null) { while (length > 0 && right !== null) {
if (right.deleted === false) { if (right.deleted === false) {
switch (right.content.constructor) { switch (right.content.constructor) {
@@ -351,6 +461,9 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
left = right left = right
right = right.right right = right.right
} }
if (start) {
cleanupFormattingGap(transaction, start, right, startAttrs, map.copy(currentAttributes))
}
return { left, right } return { left, right }
} }
@@ -649,7 +762,48 @@ export class YText extends AbstractType {
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified. * @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/ */
_callObserver (transaction, parentSubs) { _callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YTextEvent(this, transaction)) const event = new YTextEvent(this, transaction)
const doc = transaction.doc
// If a remote change happened, we try to cleanup potential formatting duplicates.
if (!transaction.local) {
// check if another formatting item was inserted
let foundFormattingItem = false
for (const [client, afterClock] of transaction.afterState) {
const clock = transaction.beforeState.get(client) || 0
if (afterClock === clock) {
continue
}
iterateStructs(transaction, /** @type {Array<Item|GC>} */ (doc.store.clients.get(client)), clock, afterClock, item => {
// @ts-ignore
if (!item.deleted && item.content.constructor === ContentFormat) {
foundFormattingItem = true
}
})
if (foundFormattingItem) {
break
}
}
transact(doc, t => {
if (foundFormattingItem) {
// If a formatting item was inserted, we simply clean the whole type.
// We need to compute currentAttributes for the current position anyway.
cleanupYTextFormatting(this)
} else {
// If no formatting attribute was inserted, we can make due with contextless
// formatting cleanups.
// Contextless: it is not necessary to compute currentAttributes for the affected position.
iterateDeletedStructs(t, transaction.deleteSet, item => {
if (item instanceof GC) {
return
}
if (item.parent === this) {
cleanupContextlessFormattingGap(t, item)
}
})
}
})
}
callTypeObservers(this, transaction, event)
} }
/** /**
@@ -916,6 +1070,9 @@ export class YText extends AbstractType {
* @public * @public
*/ */
format (index, length, attributes) { format (index, length, attributes) {
if (length === 0) {
return
}
const y = this.doc const y = this.doc
if (y !== null) { if (y !== null) {
transact(y, transaction => { transact(y, transaction => {

View File

@@ -17,6 +17,8 @@ import { Observable } from 'lib0/observable.js'
import * as random from 'lib0/random.js' import * as random from 'lib0/random.js'
import * as map from 'lib0/map.js' import * as map from 'lib0/map.js'
export const generateNewClientId = random.uint32
/** /**
* A Yjs instance handles the state of shared data. * A Yjs instance handles the state of shared data.
* @extends Observable<string> * @extends Observable<string>
@@ -31,7 +33,7 @@ export class Doc extends Observable {
super() super()
this.gc = gc this.gc = gc
this.gcFilter = gcFilter this.gcFilter = gcFilter
this.clientID = random.uint32() this.clientID = generateNewClientId()
/** /**
* @type {Map<string, AbstractType<YEvent>>} * @type {Map<string, AbstractType<YEvent>>}
*/ */

View File

@@ -10,6 +10,7 @@ import {
findIndexSS, findIndexSS,
callEventHandlerListeners, callEventHandlerListeners,
Item, Item,
generateNewClientId,
StructStore, ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line StructStore, ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
} from '../internals.js' } from '../internals.js'
@@ -17,6 +18,7 @@ import * as encoding from 'lib0/encoding.js'
import * as map from 'lib0/map.js' import * as map from 'lib0/map.js'
import * as math from 'lib0/math.js' import * as math from 'lib0/math.js'
import * as set from 'lib0/set.js' import * as set from 'lib0/set.js'
import * as logging from 'lib0/logging.js'
import { callAll } from 'lib0/function.js' import { callAll } from 'lib0/function.js'
/** /**
@@ -313,6 +315,10 @@ const cleanupTransactions = (transactionCleanups, i) => {
tryToMergeWithLeft(structs, replacedStructPos) tryToMergeWithLeft(structs, replacedStructPos)
} }
} }
if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
doc.clientID = generateNewClientId()
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
}
// @todo Merge all the transactions into one and provide send the data as a single update message // @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc]) doc.emit('afterTransactionCleanup', [transaction, doc])
if (doc._observers.has('update')) { if (doc._observers.has('update')) {

View File

@@ -19,13 +19,13 @@ import { Observable } from 'lib0/observable.js'
class StackItem { class StackItem {
/** /**
* @param {DeleteSet} ds * @param {DeleteSet} ds
* @param {number} start clock start of the local client * @param {Map<number,number>} beforeState
* @param {number} len * @param {Map<number,number>} afterState
*/ */
constructor (ds, start, len) { constructor (ds, beforeState, afterState) {
this.ds = ds this.ds = ds
this.start = start this.beforeState = beforeState
this.len = len this.afterState = afterState
/** /**
* Use this to save and restore metadata like selection range * Use this to save and restore metadata like selection range
*/ */
@@ -50,27 +50,58 @@ const popStackItem = (undoManager, stack, eventType) => {
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
const clientID = doc.clientID
const stackItem = /** @type {StackItem} */ (stack.pop()) const stackItem = /** @type {StackItem} */ (stack.pop())
const stackStartClock = stackItem.start /**
const stackEndClock = stackItem.start + stackItem.len * @type {Set<Item>}
*/
const itemsToRedo = new Set() const itemsToRedo = new Set()
// @todo iterateStructs should not need the structs parameter /**
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(clientID)) * @type {Array<Item>}
*/
const itemsToDelete = []
let performedChange = false let performedChange = false
if (stackStartClock !== stackEndClock) { stackItem.afterState.forEach((endClock, client) => {
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end) const startClock = stackItem.beforeState.get(client) || 0
getItemCleanStart(transaction, createID(clientID, stackStartClock)) const len = endClock - startClock
if (stackEndClock < getState(doc.store, clientID)) { // @todo iterateStructs should not need the structs parameter
getItemCleanStart(transaction, createID(clientID, stackEndClock)) const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
if (startClock !== endClock) {
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end)
// this must be executed before deleted structs are iterated.
getItemCleanStart(transaction, createID(client, startClock))
if (endClock < getState(doc.store, client)) {
getItemCleanStart(transaction, createID(client, endClock))
}
iterateStructs(transaction, structs, startClock, len, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
}
if (item.length > len) {
getItemCleanStart(transaction, createID(item.id.client, endClock))
}
struct = item
}
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
})
} }
} })
iterateDeletedStructs(transaction, stackItem.ds, struct => { iterateDeletedStructs(transaction, stackItem.ds, struct => {
const id = struct.id
const clock = id.clock
const client = id.client
const startClock = stackItem.beforeState.get(client) || 0
const endClock = stackItem.afterState.get(client) || 0
if ( if (
struct instanceof Item && struct instanceof Item &&
scope.some(type => isParentOf(type, struct)) && scope.some(type => isParentOf(type, struct)) &&
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval. // Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
!(struct.id.client === clientID && struct.id.clock >= stackStartClock && struct.id.clock < stackEndClock) !(clock >= startClock && clock < endClock)
) { ) {
itemsToRedo.add(struct) itemsToRedo.add(struct)
} }
@@ -78,27 +109,6 @@ const popStackItem = (undoManager, stack, eventType) => {
itemsToRedo.forEach(struct => { itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
}) })
/**
* @type {Array<Item>}
*/
const itemsToDelete = []
iterateStructs(transaction, structs, stackStartClock, stackItem.len, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
}
if (item.length > stackItem.len) {
getItemCleanStart(transaction, createID(item.id.client, stackEndClock))
}
struct = item
}
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
})
// We want to delete in reverse order so that children are deleted before // We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered. // parents, so we have more information available when items are filtered.
for (let i = itemsToDelete.length - 1; i >= 0; i--) { for (let i = itemsToDelete.length - 1; i >= 0; i--) {
@@ -181,17 +191,17 @@ export class UndoManager extends Observable {
// neither undoing nor redoing: delete redoStack // neither undoing nor redoing: delete redoStack
this.redoStack = [] this.redoStack = []
} }
const beforeState = transaction.beforeState.get(this.doc.clientID) || 0 const beforeState = transaction.beforeState
const afterState = transaction.afterState.get(this.doc.clientID) || 0 const afterState = transaction.afterState
const now = time.getUnixTime() const now = time.getUnixTime()
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) { if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op // append change to last stack op
const lastOp = stack[stack.length - 1] const lastOp = stack[stack.length - 1]
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet]) lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
lastOp.len = afterState - lastOp.start lastOp.afterState = afterState
} else { } else {
// create a new stack op // create a new stack op
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState - beforeState)) stack.push(new StackItem(transaction.deleteSet, beforeState, afterState))
} }
if (!undoing && !redoing) { if (!undoing && !redoing) {
this.lastChange = now this.lastChange = now

View File

@@ -0,0 +1,19 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing.js'
/**
* Client id should be changed when an instance receives updates from another client using the same client id.
*
* @param {t.TestCase} tc
*/
export const testClientIdDuplicateChange = tc => {
const doc1 = new Y.Doc()
doc1.clientID = 0
const doc2 = new Y.Doc()
doc2.clientID = 0
t.assert(doc2.clientID === doc1.clientID)
doc1.getArray('a').insert(0, [1, 2])
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
t.assert(doc2.clientID !== doc1.clientID)
}

View File

@@ -5,6 +5,7 @@ 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 * as undoredo from './undo-redo.tests.js'
import * as consistency from './consistency.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'
@@ -14,7 +15,7 @@ if (isBrowser) {
log.createVConsole(document.body) log.createVConsole(document.body)
} }
runTests({ runTests({
map, array, text, xml, encoding, undoredo map, array, text, xml, consistency, encoding, undoredo
}).then(success => { }).then(success => {
/* istanbul ignore next */ /* istanbul ignore next */
if (isNode) { if (isNode) {

View File

@@ -1,5 +1,8 @@
import * as Y from './testHelper.js' import * as Y from './testHelper.js'
import * as t from 'lib0/testing.js' import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
import * as math from 'lib0/math.js'
const { init, compare } = Y const { init, compare } = Y
/** /**
@@ -180,3 +183,189 @@ export const testToDeltaEmbedNoAttributes = tc => {
const delta0 = text0.toDelta() const delta0 = text0.toDelta()
t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' } }, { insert: 'b', attributes: { bold: true } }], 'toDelta does not set attributes key when no attributes are present') t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' } }, { insert: 'b', attributes: { bold: true } }], 'toDelta does not set attributes key when no attributes are present')
} }
/**
* @param {t.TestCase} tc
*/
export const testFormattingRemoved = tc => {
const { text0 } = init(tc, { users: 1 })
text0.insert(0, 'ab', { bold: true })
text0.delete(0, 2)
t.assert(Y.getTypeChildren(text0).length === 1)
}
/**
* @param {t.TestCase} tc
*/
export const testFormattingRemovedInMidText = tc => {
const { text0 } = init(tc, { users: 1 })
text0.insert(0, '1234')
text0.insert(2, 'ab', { bold: true })
text0.delete(2, 2)
t.assert(Y.getTypeChildren(text0).length === 3)
}
// RANDOM TESTS
let charCounter = 0
const marks = [
{ bold: true },
{ italic: true },
{ italic: true, color: '#888' }
]
const marksChoices = [
undefined,
...marks
]
/**
* @type Array<function(any,prng.PRNG):void>
*/
const qChanges = [
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // insert text
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.toString().length)
const attrs = prng.oneOf(gen, marksChoices)
const text = charCounter++ + prng.word(gen)
ytext.insert(insertPos, text, attrs)
},
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // insert embed
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.toString().length)
ytext.insertEmbed(insertPos, { image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' })
},
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // delete text
const ytext = y.getText('text')
const contentLen = ytext.toString().length
const insertPos = prng.int32(gen, 0, contentLen)
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
ytext.delete(insertPos, overwrite)
},
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // format text
const ytext = y.getText('text')
const contentLen = ytext.toString().length
const insertPos = prng.int32(gen, 0, contentLen)
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
const format = prng.oneOf(gen, marks)
ytext.format(insertPos, overwrite, format)
},
/**
* @param {Y.Doc} y
* @param {prng.PRNG} gen
*/
(y, gen) => { // insert codeblock
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.toString().length)
const text = charCounter++ + prng.word(gen)
const ops = []
if (insertPos > 0) {
ops.push({ retain: insertPos })
}
ops.push({ insert: text }, { insert: '\n', format: { 'code-block': true } })
ytext.applyDelta(ops)
}
]
/**
* @param {any} result
*/
const checkResult = result => {
for (let i = 1; i < result.testObjects.length; i++) {
const p1 = result.users[i].getText('text').toDelta()
const p2 = result.users[i].getText('text').toDelta()
t.compare(p1, p2)
}
// Uncomment this to find formatting-cleanup issues
// const cleanups = Y.cleanupYTextFormatting(result.users[0].getText('text'))
// t.assert(cleanups === 0)
return result
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges1 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 1))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges2 = tc => {
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges2Repeat = tc => {
for (let i = 0; i < 1000; i++) {
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
t.assert(cleanups === 0)
}
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges3 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 3))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges30 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 30))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges40 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 40))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges70 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 70))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges100 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 100))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGenerateQuillChanges300 = tc => {
checkResult(Y.applyRandomTests(tc, qChanges, 300))
}