Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f7631d1e | ||
|
|
3bb107504f | ||
|
|
39803c1d11 | ||
|
|
46fae57036 | ||
|
|
e9cb07da55 | ||
|
|
114f28f48e | ||
|
|
a1da486c8a | ||
|
|
4fb9cc2a30 | ||
|
|
e2c9eb7f01 | ||
|
|
6fd33c0720 | ||
|
|
72f3ce75b2 | ||
|
|
fd211731cc | ||
|
|
8049776074 | ||
|
|
32b1338d48 | ||
|
|
c2f0ca3fae | ||
|
|
dfc6b879de | ||
|
|
81f16ff0b5 | ||
|
|
e1a2ccd7f6 | ||
|
|
be8cc8a20c | ||
|
|
a253cfc090 | ||
|
|
fef3fc2a4a | ||
|
|
eee695eeeb | ||
|
|
38e38a92dc | ||
|
|
7193ae63b7 | ||
|
|
4d48224518 | ||
|
|
9c0d1eb209 | ||
|
|
ceba4b1837 |
@@ -12,7 +12,7 @@ which aren't described in the paper. The most notable is that items have an
|
|||||||
`originRight` as well as an `origin` property, which improves performance when
|
`originRight` as well as an `origin` property, which improves performance when
|
||||||
many concurrent inserts happen after the same character.
|
many concurrent inserts happen after the same character.
|
||||||
|
|
||||||
At it heart, Yjs is a list CRDT. Everything is squeezed into a list in order to
|
At its heart, Yjs is a list CRDT. Everything is squeezed into a list in order to
|
||||||
reuse the CRDT resolution algorithm:
|
reuse the CRDT resolution algorithm:
|
||||||
|
|
||||||
- Arrays are easy - they're lists of arbitrary items.
|
- Arrays are easy - they're lists of arbitrary items.
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ const ydoc = new Y.Doc()
|
|||||||
|
|
||||||
// this allows you to instantly get the (cached) documents data
|
// this allows you to instantly get the (cached) documents data
|
||||||
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
|
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
|
||||||
idbP.whenSynced.then(() => {
|
indexeddbProvider.whenSynced.then(() => {
|
||||||
console.log('loaded data from indexed db')
|
console.log('loaded data from indexed db')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "yjs",
|
"name": "yjs",
|
||||||
"version": "13.4.0-0",
|
"version": "13.4.3",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "yjs",
|
"name": "yjs",
|
||||||
"version": "13.4.0-0",
|
"version": "13.4.3",
|
||||||
"description": "Shared Editing Library",
|
"description": "Shared Editing Library",
|
||||||
"main": "./dist/yjs.cjs",
|
"main": "./dist/yjs.cjs",
|
||||||
"module": "./dist/yjs.mjs",
|
"module": "./dist/yjs.mjs",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export {
|
|||||||
findRootTypeKey,
|
findRootTypeKey,
|
||||||
typeListToArraySnapshot,
|
typeListToArraySnapshot,
|
||||||
typeMapGetSnapshot,
|
typeMapGetSnapshot,
|
||||||
|
createDocFromSnapshot,
|
||||||
iterateDeletedStructs,
|
iterateDeletedStructs,
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
applyUpdateV2,
|
applyUpdateV2,
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ export class ContentString {
|
|||||||
splice (offset) {
|
splice (offset) {
|
||||||
const right = new ContentString(this.str.slice(offset))
|
const right = new ContentString(this.str.slice(offset))
|
||||||
this.str = this.str.slice(0, offset)
|
this.str = this.str.slice(0, offset)
|
||||||
|
|
||||||
|
// Prevent encoding invalid documents because of splitting of surrogate pairs: https://github.com/yjs/yjs/issues/248
|
||||||
|
const firstCharCode = this.str.charCodeAt(offset - 1)
|
||||||
|
if (firstCharCode >= 0xD800 && firstCharCode <= 0xDBFF) {
|
||||||
|
// Last character of the left split is the start of a surrogate utf16/ucs2 pair.
|
||||||
|
// We don't support splitting of surrogate pairs because this may lead to invalid documents.
|
||||||
|
// Replace the invalid character with a unicode replacement character (<28> / U+FFFD)
|
||||||
|
this.str = this.str.slice(0, offset - 1) + '<27>'
|
||||||
|
// replace right as well
|
||||||
|
right.str = '<27>' + right.str.slice(1)
|
||||||
|
}
|
||||||
return right
|
return right
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ import {
|
|||||||
} from '../internals.js'
|
} from '../internals.js'
|
||||||
|
|
||||||
import * as error from 'lib0/error.js'
|
import * as error from 'lib0/error.js'
|
||||||
import * as maplib from 'lib0/map.js'
|
|
||||||
import * as set from 'lib0/set.js'
|
|
||||||
import * as binary from 'lib0/binary.js'
|
import * as binary from 'lib0/binary.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -594,7 +592,7 @@ export class Item extends AbstractStruct {
|
|||||||
}
|
}
|
||||||
this.markDeleted()
|
this.markDeleted()
|
||||||
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
|
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
|
||||||
maplib.setIfUndefined(transaction.changed, parent, set.create).add(this.parentSub)
|
addChangedTypeToTransaction(transaction, parent, this.parentSub)
|
||||||
this.content.delete(transaction)
|
this.content.delete(transaction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,9 +132,9 @@ export class YMap extends AbstractType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the keys for each element in the YMap Type.
|
* Returns the values for each element in the YMap Type.
|
||||||
*
|
*
|
||||||
* @return {IterableIterator<string>}
|
* @return {IterableIterator<any>}
|
||||||
*/
|
*/
|
||||||
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])
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ export const addEventHandlerListener = (eventHandler, f) =>
|
|||||||
* @function
|
* @function
|
||||||
*/
|
*/
|
||||||
export const removeEventHandlerListener = (eventHandler, f) => {
|
export const removeEventHandlerListener = (eventHandler, f) => {
|
||||||
eventHandler.l = eventHandler.l.filter(g => f !== g)
|
const l = eventHandler.l
|
||||||
|
const len = l.length
|
||||||
|
eventHandler.l = l.filter(g => f !== g)
|
||||||
|
if (len === eventHandler.l.length) {
|
||||||
|
console.error('[yjs] Tried to remove event handler that doesn\'t exist.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class PermanentUserData {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(new DSDecoderV1(encodedDs)))))
|
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(new DSDecoderV1(decoding.createDecoder(encodedDs))))))
|
||||||
ids.observe(/** @param {YArrayEvent<any>} event */ event =>
|
ids.observe(/** @param {YArrayEvent<any>} event */ event =>
|
||||||
event.changes.added.forEach(item => item.content.getContent().forEach(addClientId))
|
event.changes.added.forEach(item => item.content.getContent().forEach(addClientId))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ import {
|
|||||||
createDeleteSet,
|
createDeleteSet,
|
||||||
createID,
|
createID,
|
||||||
getState,
|
getState,
|
||||||
AbstractDSDecoder, AbstractDSEncoder, DSEncoderV1, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item // eslint-disable-line
|
findIndexSS,
|
||||||
|
UpdateEncoderV2,
|
||||||
|
DefaultDSEncoder,
|
||||||
|
applyUpdateV2,
|
||||||
|
AbstractDSDecoder, AbstractDSEncoder, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item // eslint-disable-line
|
||||||
} from '../internals.js'
|
} from '../internals.js'
|
||||||
|
|
||||||
import * as map from 'lib0/map.js'
|
import * as map from 'lib0/map.js'
|
||||||
import * as set from 'lib0/set.js'
|
import * as set from 'lib0/set.js'
|
||||||
import * as decoding from 'lib0/decoding.js'
|
import * as decoding from 'lib0/decoding.js'
|
||||||
import { DefaultDSEncoder } from './encoding.js'
|
import * as encoding from 'lib0/encoding.js'
|
||||||
|
|
||||||
export class Snapshot {
|
export class Snapshot {
|
||||||
/**
|
/**
|
||||||
@@ -148,3 +152,51 @@ export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
|
|||||||
meta.add(snapshot)
|
meta.add(snapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Doc} originDoc
|
||||||
|
* @param {Snapshot} snapshot
|
||||||
|
* @param {Doc} [newDoc] Optionally, you may define the Yjs document that receives the data from originDoc
|
||||||
|
* @return {Doc}
|
||||||
|
*/
|
||||||
|
export const createDocFromSnapshot = (originDoc, snapshot, newDoc = new Doc()) => {
|
||||||
|
if (originDoc.gc) {
|
||||||
|
// we should not try to restore a GC-ed document, because some of the restored items might have their content deleted
|
||||||
|
throw new Error('originDoc must not be garbage collected')
|
||||||
|
}
|
||||||
|
const { sv, ds } = snapshot
|
||||||
|
|
||||||
|
const encoder = new UpdateEncoderV2()
|
||||||
|
originDoc.transact(transaction => {
|
||||||
|
let size = 0
|
||||||
|
sv.forEach(clock => {
|
||||||
|
if (clock > 0) {
|
||||||
|
size++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, size)
|
||||||
|
// splitting the structs before writing them to the encoder
|
||||||
|
for (const [client, clock] of sv) {
|
||||||
|
if (clock === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (clock < getState(originDoc.store, client)) {
|
||||||
|
getItemCleanStart(transaction, createID(client, clock))
|
||||||
|
}
|
||||||
|
const structs = originDoc.store.clients.get(client) || []
|
||||||
|
const lastStructIndex = findIndexSS(structs, clock - 1)
|
||||||
|
// write # encoded structs
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, lastStructIndex + 1)
|
||||||
|
encoder.writeClient(client)
|
||||||
|
// first clock written is 0
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, 0)
|
||||||
|
for (let i = 0; i <= lastStructIndex; i++) {
|
||||||
|
structs[i].write(encoder, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeDeleteSet(encoder, ds)
|
||||||
|
})
|
||||||
|
|
||||||
|
applyUpdateV2(newDoc, encoder.toUint8Array(), 'snapshot')
|
||||||
|
return newDoc
|
||||||
|
}
|
||||||
|
|||||||
@@ -284,6 +284,9 @@ const cleanupTransactions = (transactionCleanups, i) => {
|
|||||||
.forEach(event => {
|
.forEach(event => {
|
||||||
event.currentTarget = type
|
event.currentTarget = type
|
||||||
})
|
})
|
||||||
|
// sort events by path length so that top-level events are fired first.
|
||||||
|
events
|
||||||
|
.sort((event1, event2) => event1.path.length - event2.path.length)
|
||||||
// We don't need to check for events.length
|
// We don't need to check for events.length
|
||||||
// because we know it has at least one element
|
// because we know it has at least one element
|
||||||
callEventHandlerListeners(type._dEH, events, transaction)
|
callEventHandlerListeners(type._dEH, events, transaction)
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ const popStackItem = (undoManager, stack, eventType) => {
|
|||||||
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
|
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
transaction.changed.forEach((subProps, type) => {
|
||||||
|
// destroy search marker if necessary
|
||||||
|
if (subProps.has(null) && type._searchMarker) {
|
||||||
|
type._searchMarker.length = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
}, undoManager)
|
}, undoManager)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -151,10 +157,7 @@ export class UndoManager extends Observable {
|
|||||||
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
|
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
|
||||||
* @param {UndoManagerOptions} options
|
* @param {UndoManagerOptions} options
|
||||||
*/
|
*/
|
||||||
constructor (typeScope, { captureTimeout, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
|
constructor (typeScope, { captureTimeout = 500, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
|
||||||
if (captureTimeout == null) {
|
|
||||||
captureTimeout = 500
|
|
||||||
}
|
|
||||||
super()
|
super()
|
||||||
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
|
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
|
||||||
this.deleteFilter = deleteFilter
|
this.deleteFilter = deleteFilter
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export class YEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return {{added:Set<Item>,deleted:Set<Item>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
|
* @return {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
|
||||||
*/
|
*/
|
||||||
get changes () {
|
get changes () {
|
||||||
let changes = this._changes
|
let changes = this._changes
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as t from 'lib0/testing.js'
|
import * as t from 'lib0/testing.js'
|
||||||
|
import * as promise from 'lib0/promise.js'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
contentRefs,
|
contentRefs,
|
||||||
@@ -10,7 +11,11 @@ import {
|
|||||||
readContentType,
|
readContentType,
|
||||||
readContentFormat,
|
readContentFormat,
|
||||||
readContentAny,
|
readContentAny,
|
||||||
readContentDoc
|
readContentDoc,
|
||||||
|
Doc,
|
||||||
|
PermanentUserData,
|
||||||
|
encodeStateAsUpdate,
|
||||||
|
applyUpdate
|
||||||
} from '../src/internals.js'
|
} from '../src/internals.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,3 +33,31 @@ export const testStructReferences = tc => {
|
|||||||
t.assert(contentRefs[8] === readContentAny)
|
t.assert(contentRefs[8] === readContentAny)
|
||||||
t.assert(contentRefs[9] === readContentDoc)
|
t.assert(contentRefs[9] === readContentDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is some custom encoding/decoding happening in PermanentUserData.
|
||||||
|
* This is why it landed here.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testPermanentUserData = async tc => {
|
||||||
|
const ydoc1 = new Doc()
|
||||||
|
const ydoc2 = new Doc()
|
||||||
|
const pd1 = new PermanentUserData(ydoc1)
|
||||||
|
const pd2 = new PermanentUserData(ydoc2)
|
||||||
|
pd1.setUserMapping(ydoc1, ydoc1.clientID, 'user a')
|
||||||
|
pd2.setUserMapping(ydoc2, ydoc2.clientID, 'user b')
|
||||||
|
ydoc1.getText().insert(0, 'xhi')
|
||||||
|
ydoc1.getText().delete(0, 1)
|
||||||
|
ydoc2.getText().insert(0, 'hxxi')
|
||||||
|
ydoc2.getText().delete(1, 2)
|
||||||
|
await promise.wait(10)
|
||||||
|
applyUpdate(ydoc2, encodeStateAsUpdate(ydoc1))
|
||||||
|
applyUpdate(ydoc1, encodeStateAsUpdate(ydoc2))
|
||||||
|
|
||||||
|
// now sync a third doc with same name as doc1 and then create PermanentUserData
|
||||||
|
const ydoc3 = new Doc()
|
||||||
|
applyUpdate(ydoc3, encodeStateAsUpdate(ydoc1))
|
||||||
|
const pd3 = new PermanentUserData(ydoc3)
|
||||||
|
pd3.setUserMapping(ydoc3, ydoc3.clientID, 'user a')
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 compatibility from './compatibility.tests.js'
|
import * as compatibility from './compatibility.tests.js'
|
||||||
import * as doc from './doc.tests.js'
|
import * as doc from './doc.tests.js'
|
||||||
|
import * as snapshot from './snapshot.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'
|
||||||
@@ -16,7 +17,7 @@ if (isBrowser) {
|
|||||||
log.createVConsole(document.body)
|
log.createVConsole(document.body)
|
||||||
}
|
}
|
||||||
runTests({
|
runTests({
|
||||||
doc, map, array, text, xml, encoding, undoredo, compatibility
|
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot
|
||||||
}).then(success => {
|
}).then(success => {
|
||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
if (isNode) {
|
if (isNode) {
|
||||||
|
|||||||
171
tests/snapshot.tests.js
Normal file
171
tests/snapshot.tests.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { createDocFromSnapshot, Doc, snapshot, YMap } from '../src/internals'
|
||||||
|
import * as t from 'lib0/testing.js'
|
||||||
|
import { init } from './testHelper'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testBasicRestoreSnapshot = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['hello'])
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').insert(1, ['world'])
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), ['hello'])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['hello', 'world'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testEmptyRestoreSnapshot = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
snap.sv.set(9999, 0)
|
||||||
|
doc.getArray().insert(0, ['world'])
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray().toArray(), [])
|
||||||
|
t.compare(doc.getArray().toArray(), ['world'])
|
||||||
|
|
||||||
|
// now this snapshot reflects the latest state. It shoult still work.
|
||||||
|
const snap2 = snapshot(doc)
|
||||||
|
const docRestored2 = createDocFromSnapshot(doc, snap2)
|
||||||
|
t.compare(docRestored2.getArray().toArray(), ['world'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRestoreSnapshotWithSubType = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, [new YMap()])
|
||||||
|
const subMap = doc.getArray('array').get(0)
|
||||||
|
subMap.set('key1', 'value1')
|
||||||
|
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
subMap.set('key2', 'value2')
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toJSON(), [{
|
||||||
|
key1: 'value1'
|
||||||
|
}])
|
||||||
|
t.compare(doc.getArray('array').toJSON(), [{
|
||||||
|
key1: 'value1',
|
||||||
|
key2: 'value2'
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRestoreDeletedItem1 = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['item1', 'item2'])
|
||||||
|
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').delete(0)
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item2'])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['item2'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRestoreLeftItem = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['item1'])
|
||||||
|
doc.getMap('map').set('test', 1)
|
||||||
|
doc.getArray('array').insert(0, ['item0'])
|
||||||
|
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').delete(1)
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), ['item0', 'item1'])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['item0'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDeletedItemsBase = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['item1'])
|
||||||
|
doc.getArray('array').delete(0)
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').insert(0, ['item0'])
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), [])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['item0'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDeletedItems2 = tc => {
|
||||||
|
const doc = new Doc({ gc: false })
|
||||||
|
doc.getArray('array').insert(0, ['item1', 'item2', 'item3'])
|
||||||
|
doc.getArray('array').delete(1)
|
||||||
|
const snap = snapshot(doc)
|
||||||
|
doc.getArray('array').insert(0, ['item0'])
|
||||||
|
|
||||||
|
const docRestored = createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item3'])
|
||||||
|
t.compare(doc.getArray('array').toArray(), ['item0', 'item1', 'item3'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDependentChanges = tc => {
|
||||||
|
const { array0, array1, testConnector } = init(tc, { users: 2 })
|
||||||
|
|
||||||
|
if (!array0.doc) {
|
||||||
|
throw new Error('no document 0')
|
||||||
|
}
|
||||||
|
if (!array1.doc) {
|
||||||
|
throw new Error('no document 1')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type Doc
|
||||||
|
*/
|
||||||
|
const doc0 = array0.doc
|
||||||
|
/**
|
||||||
|
* @type Doc
|
||||||
|
*/
|
||||||
|
const doc1 = array1.doc
|
||||||
|
|
||||||
|
doc0.gc = false
|
||||||
|
doc1.gc = false
|
||||||
|
|
||||||
|
array0.insert(0, ['user1item1'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
array1.insert(1, ['user2item1'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
|
||||||
|
const snap = snapshot(array0.doc)
|
||||||
|
|
||||||
|
array0.insert(2, ['user1item2'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
array1.insert(3, ['user2item2'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
|
||||||
|
const docRestored0 = createDocFromSnapshot(array0.doc, snap)
|
||||||
|
t.compare(docRestored0.getArray('array').toArray(), ['user1item1', 'user2item1'])
|
||||||
|
|
||||||
|
const docRestored1 = createDocFromSnapshot(array1.doc, snap)
|
||||||
|
t.compare(docRestored1.getArray('array').toArray(), ['user1item1', 'user2item1'])
|
||||||
|
}
|
||||||
@@ -53,6 +53,28 @@ export const testUndoText = tc => {
|
|||||||
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
|
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test case to fix #241
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDoubleUndo = tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const text = doc.getText()
|
||||||
|
text.insert(0, '1221')
|
||||||
|
|
||||||
|
const manager = new Y.UndoManager(text)
|
||||||
|
|
||||||
|
text.insert(2, '3')
|
||||||
|
text.insert(3, '3')
|
||||||
|
|
||||||
|
manager.undo()
|
||||||
|
manager.undo()
|
||||||
|
|
||||||
|
text.insert(2, '3')
|
||||||
|
|
||||||
|
t.compareStrings(text.toString(), '12321')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {t.TestCase} tc
|
* @param {t.TestCase} tc
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -204,6 +204,34 @@ export const testInsertAndDeleteEventsForTypes = tc => {
|
|||||||
compare(users)
|
compare(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This issue has been reported in https://discuss.yjs.dev/t/order-in-which-events-yielded-by-observedeep-should-be-applied/261/2
|
||||||
|
*
|
||||||
|
* Deep observers generate multiple events. When an array added at item at, say, position 0,
|
||||||
|
* and item 1 changed then the array-add event should fire first so that the change event
|
||||||
|
* path is correct. A array binding might lead to an inconsistent state otherwise.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testObserveDeepEventOrder = tc => {
|
||||||
|
const { array0, users } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {Array<any>}
|
||||||
|
*/
|
||||||
|
let events = []
|
||||||
|
array0.observeDeep(e => {
|
||||||
|
events = e
|
||||||
|
})
|
||||||
|
array0.insert(0, [new Y.Map()])
|
||||||
|
users[0].transact(() => {
|
||||||
|
array0.get(0).set('a', 'a')
|
||||||
|
array0.insert(0, [0])
|
||||||
|
})
|
||||||
|
for (let i = 1; i < events.length; i++) {
|
||||||
|
t.assert(events[i - 1].path.length <= events[i].path.length, 'path size increases, fire top-level events first')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {t.TestCase} tc
|
* @param {t.TestCase} tc
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -249,6 +249,8 @@ export const testAppendChars = tc => {
|
|||||||
t.assert(text0.length === N)
|
t.assert(text0.length === N)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const largeDocumentSize = 100000
|
||||||
|
|
||||||
const id = Y.createID(0, 0)
|
const id = Y.createID(0, 0)
|
||||||
const c = new Y.ContentString('a')
|
const c = new Y.ContentString('a')
|
||||||
|
|
||||||
@@ -256,7 +258,7 @@ const c = new Y.ContentString('a')
|
|||||||
* @param {t.TestCase} tc
|
* @param {t.TestCase} tc
|
||||||
*/
|
*/
|
||||||
export const testBestCase = tc => {
|
export const testBestCase = tc => {
|
||||||
const N = 2000000
|
const N = largeDocumentSize
|
||||||
const items = new Array(N)
|
const items = new Array(N)
|
||||||
t.measureTime('time to create two million items in the best case', () => {
|
t.measureTime('time to create two million items in the best case', () => {
|
||||||
const parent = /** @type {any} */ ({})
|
const parent = /** @type {any} */ ({})
|
||||||
@@ -293,7 +295,7 @@ const tryGc = () => {
|
|||||||
* @param {t.TestCase} tc
|
* @param {t.TestCase} tc
|
||||||
*/
|
*/
|
||||||
export const testLargeFragmentedDocument = tc => {
|
export const testLargeFragmentedDocument = tc => {
|
||||||
const itemsToInsert = 1000000
|
const itemsToInsert = largeDocumentSize
|
||||||
let update = /** @type {any} */ (null)
|
let update = /** @type {any} */ (null)
|
||||||
;(() => {
|
;(() => {
|
||||||
const doc1 = new Y.Doc()
|
const doc1 = new Y.Doc()
|
||||||
@@ -321,6 +323,40 @@ export const testLargeFragmentedDocument = tc => {
|
|||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splitting surrogates can lead to invalid encoded documents.
|
||||||
|
*
|
||||||
|
* https://github.com/yjs/yjs/issues/248
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testSplitSurrogateCharacter = tc => {
|
||||||
|
{
|
||||||
|
const { users, text0 } = init(tc, { users: 2 })
|
||||||
|
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
|
||||||
|
text0.insert(0, '👾') // insert surrogate character
|
||||||
|
// split surrogate, which should not lead to an encoding error
|
||||||
|
text0.insert(1, 'hi!')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { users, text0 } = init(tc, { users: 2 })
|
||||||
|
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
|
||||||
|
text0.insert(0, '👾👾') // insert surrogate character
|
||||||
|
// partially delete surrogate
|
||||||
|
text0.delete(1, 2)
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { users, text0 } = init(tc, { users: 2 })
|
||||||
|
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
|
||||||
|
text0.insert(0, '👾👾') // insert surrogate character
|
||||||
|
// formatting will also split surrogates
|
||||||
|
text0.format(1, 2, { bold: true })
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RANDOM TESTS
|
// RANDOM TESTS
|
||||||
|
|
||||||
let charCounter = 0
|
let charCounter = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user