Merge 15279e430c0c0125fd0642ef76d183db1b5d3422 into 8cd1a482bbcfb34c1a557afc7a15cdbb1816b7fd

This commit is contained in:
Bartosz Sypytkowski 2024-09-26 22:58:40 +03:00 committed by GitHub
commit 2260b86a56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1922 additions and 144 deletions

View File

@ -10,11 +10,14 @@ export {
YXmlHook as XmlHook, YXmlHook as XmlHook,
YXmlElement as XmlElement, YXmlElement as XmlElement,
YXmlFragment as XmlFragment, YXmlFragment as XmlFragment,
YWeakLink as WeakLink,
YWeakLinkEvent,
YXmlEvent, YXmlEvent,
YMapEvent, YMapEvent,
YArrayEvent, YArrayEvent,
YTextEvent, YTextEvent,
YEvent, YEvent,
YRange as Range,
Item, Item,
AbstractStruct, AbstractStruct,
GC, GC,

View File

@ -16,6 +16,7 @@ export * from './utils/Transaction.js'
export * from './utils/UndoManager.js' export * from './utils/UndoManager.js'
export * from './utils/updates.js' export * from './utils/updates.js'
export * from './utils/YEvent.js' export * from './utils/YEvent.js'
export * from './utils/YRange.js'
export * from './types/AbstractType.js' export * from './types/AbstractType.js'
export * from './types/YArray.js' export * from './types/YArray.js'
@ -26,6 +27,7 @@ export * from './types/YXmlElement.js'
export * from './types/YXmlEvent.js' export * from './types/YXmlEvent.js'
export * from './types/YXmlHook.js' export * from './types/YXmlHook.js'
export * from './types/YXmlText.js' export * from './types/YXmlText.js'
export * from './types/YWeakLink.js'
export * from './structs/AbstractStruct.js' export * from './structs/AbstractStruct.js'
export * from './structs/GC.js' export * from './structs/GC.js'

View File

@ -6,7 +6,10 @@ import {
readYXmlFragment, readYXmlFragment,
readYXmlHook, readYXmlHook,
readYXmlText, readYXmlText,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line readYWeakLink,
unlinkFrom,
YWeakLink,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item, YEvent, AbstractType, ID, // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as error from 'lib0/error' import * as error from 'lib0/error'
@ -22,7 +25,8 @@ export const typeRefs = [
readYXmlElement, readYXmlElement,
readYXmlFragment, readYXmlFragment,
readYXmlHook, readYXmlHook,
readYXmlText readYXmlText,
readYWeakLink
] ]
export const YArrayRefID = 0 export const YArrayRefID = 0
@ -32,6 +36,7 @@ export const YXmlElementRefID = 3
export const YXmlFragmentRefID = 4 export const YXmlFragmentRefID = 4
export const YXmlHookRefID = 5 export const YXmlHookRefID = 5
export const YXmlTextRefID = 6 export const YXmlTextRefID = 6
export const YWeakLinkRefID = 7
/** /**
* @private * @private
@ -103,6 +108,22 @@ export class ContentType {
* @param {Transaction} transaction * @param {Transaction} transaction
*/ */
delete (transaction) { delete (transaction) {
if (this.type.constructor === YWeakLink) {
// when removing weak links, remove references to them
// from type they're pointing to
const type = /** @type {YWeakLink<any>} */ (this.type)
const end = /** @type {ID} */ (type._quoteEnd.item)
for (let item = type._firstItem; item !== null; item = item.right) {
if (item.linked) {
unlinkFrom(transaction, item, type)
}
const lastId = item.lastId
if (lastId.client === end.client && lastId.clock === end.clock) {
break
}
}
type._firstItem = null
}
let item = this.type._start let item = this.type._start
while (item !== null) { while (item !== null) {
if (!item.deleted) { if (!item.deleted) {

View File

@ -22,7 +22,9 @@ import {
readContentType, readContentType,
addChangedTypeToTransaction, addChangedTypeToTransaction,
isDeleted, isDeleted,
StackItem, DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line StackItem, DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction, // eslint-disable-line
YWeakLink,
joinLinkedRange
} from '../internals.js' } from '../internals.js'
import * as error from 'lib0/error' import * as error from 'lib0/error'
@ -104,6 +106,14 @@ export const splitItem = (transaction, leftItem, diff) => {
if (leftItem.redone !== null) { if (leftItem.redone !== null) {
rightItem.redone = createID(leftItem.redone.client, leftItem.redone.clock + diff) rightItem.redone = createID(leftItem.redone.client, leftItem.redone.clock + diff)
} }
if (leftItem.linked) {
rightItem.linked = true
const allLinks = transaction.doc.store.linkedBy
const linkedBy = allLinks.get(leftItem)
if (linkedBy !== undefined) {
allLinks.set(rightItem, new Set(linkedBy))
}
}
// 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
@ -303,11 +313,28 @@ export class Item extends AbstractStruct {
* bit2: countable * bit2: countable
* bit3: deleted * bit3: deleted
* bit4: mark - mark node as fast-search-marker * bit4: mark - mark node as fast-search-marker
* bit9: linked - this item is linked by Weak Link references
* @type {number} byte * @type {number} byte
*/ */
this.info = this.content.isCountable() ? binary.BIT2 : 0 this.info = this.content.isCountable() ? binary.BIT2 : 0
} }
/**
* This is used to mark the item as linked by weak link references.
* Reference dependencies are being kept in StructStore.
*
* @type {boolean}
*/
set linked (isLinked) {
if (((this.info & binary.BIT9) > 0) !== isLinked) {
this.info ^= binary.BIT9
}
}
get linked () {
return (this.info & binary.BIT9) > 0
}
/** /**
* This is used to mark the item as an indexed fast-search marker * This is used to mark the item as an indexed fast-search marker
* *
@ -376,6 +403,20 @@ export class Item extends AbstractStruct {
return this.parent.client return this.parent.client
} }
if (this.content.constructor === ContentType && /** @type {ContentType} */ (this.content).type.constructor === YWeakLink) {
// make sure that linked content is integrated first
const content = /** @type {ContentType} */ (this.content)
const link = /** @type {YWeakLink<any>} */ (content.type)
const start = link._quoteStart.item
if (start !== null && start.clock >= getState(store, start.client)) {
return start.client
}
const end = link._quoteEnd.item
if (end !== null && end.clock >= getState(store, end.client)) {
return end.client
}
}
// We have all missing ids, now find the items // We have all missing ids, now find the items
if (this.origin) { if (this.origin) {
@ -507,18 +548,43 @@ export class Item extends AbstractStruct {
// set as current parent value if right === null and this is parentSub // set as current parent value if right === null and this is parentSub
/** @type {AbstractType<any>} */ (this.parent)._map.set(this.parentSub, this) /** @type {AbstractType<any>} */ (this.parent)._map.set(this.parentSub, this)
if (this.left !== null) { if (this.left !== null) {
// move links from block we're overriding
this.linked = this.left.linked
this.left.linked = false
const allLinks = transaction.doc.store.linkedBy
const links = allLinks.get(this.left)
if (links !== undefined) {
allLinks.set(this, links)
// since left is being deleted, it will remove
// its links from store.linkedBy anyway
}
// this is the current attribute value of parent. delete right // this is the current attribute value of parent. delete right
this.left.delete(transaction) this.left.delete(transaction)
} }
} }
// adjust length of parent if (this.parentSub === null && !this.deleted) {
if (this.parentSub === null && this.countable && !this.deleted) { if (this.countable) {
/** @type {AbstractType<any>} */ (this.parent)._length += this.length // adjust length of parent
/** @type {AbstractType<any>} */ (this.parent)._length += this.length
}
if ((this.left && this.left.linked) || (this.right && this.right.linked)) {
// this item may exists within a quoted range
joinLinkedRange(transaction, this)
}
} }
addStruct(transaction.doc.store, this) addStruct(transaction.doc.store, this)
this.content.integrate(transaction, this) this.content.integrate(transaction, this)
// add parent to transaction.changed // add parent to transaction.changed
addChangedTypeToTransaction(transaction, /** @type {AbstractType<any>} */ (this.parent), this.parentSub) addChangedTypeToTransaction(transaction, /** @type {AbstractType<any>} */ (this.parent), this.parentSub)
if (this.linked) {
// notify links about changes
const linkedBy = transaction.doc.store.linkedBy.get(this)
if (linkedBy !== undefined) {
for (const link of linkedBy) {
addChangedTypeToTransaction(transaction, link, this.parentSub)
}
}
}
if ((/** @type {AbstractType<any>} */ (this.parent)._item !== null && /** @type {AbstractType<any>} */ (this.parent)._item.deleted) || (this.parentSub !== null && this.right !== null)) { if ((/** @type {AbstractType<any>} */ (this.parent)._item !== null && /** @type {AbstractType<any>} */ (this.parent)._item.deleted) || (this.parentSub !== null && this.right !== null)) {
// delete if parent is deleted or if this is not the current attribute value of parent // delete if parent is deleted or if this is not the current attribute value of parent
this.delete(transaction) this.delete(transaction)
@ -576,6 +642,7 @@ export class Item extends AbstractStruct {
this.deleted === right.deleted && this.deleted === right.deleted &&
this.redone === null && this.redone === null &&
right.redone === null && right.redone === null &&
!this.linked && !right.linked && // linked items cannot be merged
this.content.constructor === right.content.constructor && this.content.constructor === right.content.constructor &&
this.content.mergeWith(right.content) this.content.mergeWith(right.content)
) { ) {
@ -621,6 +688,19 @@ export class Item extends AbstractStruct {
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length) addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
addChangedTypeToTransaction(transaction, parent, this.parentSub) addChangedTypeToTransaction(transaction, parent, this.parentSub)
this.content.delete(transaction) this.content.delete(transaction)
if (this.linked) {
// notify links that current element has been removed
const allLinks = transaction.doc.store.linkedBy
const linkedBy = allLinks.get(this)
if (linkedBy !== undefined) {
for (const link of linkedBy) {
addChangedTypeToTransaction(transaction, link, this.parentSub)
}
allLinks.delete(this)
}
this.linked = false
}
} }
} }

View File

@ -10,7 +10,7 @@ import {
ContentAny, ContentAny,
ContentBinary, ContentBinary,
getItemCleanStart, getItemCleanStart,
ContentDoc, YText, YArray, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line ContentDoc, YText, YArray, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, YWeakLink, // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as map from 'lib0/map' import * as map from 'lib0/map'
@ -232,8 +232,9 @@ export const getTypeChildren = t => {
* @param {AbstractType<EventType>} type * @param {AbstractType<EventType>} type
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {EventType} event * @param {EventType} event
* @param {Set<YWeakLink<any>>|null} visitedLinks
*/ */
export const callTypeObservers = (type, transaction, event) => { export const callTypeObservers = (type, transaction, event, visitedLinks = null) => {
const changedType = type const changedType = type
const changedParentTypes = transaction.changedParentTypes const changedParentTypes = transaction.changedParentTypes
while (true) { while (true) {
@ -241,6 +242,18 @@ export const callTypeObservers = (type, transaction, event) => {
map.setIfUndefined(changedParentTypes, type, () => []).push(event) map.setIfUndefined(changedParentTypes, type, () => []).push(event)
if (type._item === null) { if (type._item === null) {
break break
} else if (type._item.linked) {
const linkedBy = transaction.doc.store.linkedBy.get(type._item)
if (linkedBy !== undefined) {
for (const link of linkedBy) {
if (visitedLinks === null || !visitedLinks.has(link)) {
visitedLinks = visitedLinks !== null ? visitedLinks : new Set()
visitedLinks.add(link)
// recursive call
callTypeObservers(link, transaction, /** @type {any} */ (event), visitedLinks)
}
}
}
} }
type = /** @type {AbstractType<any>} */ (type._item.parent) type = /** @type {AbstractType<any>} */ (type._item.parent)
} }

View File

@ -16,7 +16,8 @@ import {
YArrayRefID, YArrayRefID,
callTypeObservers, callTypeObservers,
transact, transact,
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line quoteRange,
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item, YWeakLink, YRange, // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import { typeListSlice } from './AbstractType.js' import { typeListSlice } from './AbstractType.js'
@ -196,6 +197,23 @@ export class YArray extends AbstractType {
return typeListGet(this, index) return typeListGet(this, index)
} }
/**
* Returns the weak link that allows to refer and observe live changes of contents of an YArray.
* It points at a consecutive range of elements, starting at give `index` and spanning over provided
* length of elements.
*
* @param {YRange} range quoted range
* @return {YWeakLink<T>}
*/
quote (range) {
if (this.doc !== null) {
return transact(this.doc, transaction => {
return quoteRange(transaction, this, range)
})
}
throw new Error('cannot quote an YArray that has not been integrated into YDoc')
}
/** /**
* Transforms this YArray to a JavaScript Array. * Transforms this YArray to a JavaScript Array.
* *

View File

@ -13,7 +13,8 @@ import {
YMapRefID, YMapRefID,
callTypeObservers, callTypeObservers,
transact, transact,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line mapWeakLink,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item, YWeakLink, // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as iterator from 'lib0/iterator' import * as iterator from 'lib0/iterator'
@ -236,6 +237,16 @@ export class YMap extends AbstractType {
return /** @type {any} */ (typeMapGet(this, key)) return /** @type {any} */ (typeMapGet(this, key))
} }
/**
* Returns a weak reference link to another element stored in the same document.
*
* @param {string} key
* @return {YWeakLink<MapType>|undefined}
*/
link (key) {
return mapWeakLink(this, key)
}
/** /**
* Returns a boolean indicating whether the specified key exists or not. * Returns a boolean indicating whether the specified key exists or not.
* *

View File

@ -25,8 +25,9 @@ import {
typeMapGet, typeMapGet,
typeMapGetAll, typeMapGetAll,
updateMarkerChanges, updateMarkerChanges,
quoteRange,
ContentType, ContentType,
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction, YWeakLink, YRange, // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as object from 'lib0/object' import * as object from 'lib0/object'
@ -1004,107 +1005,7 @@ export class YText extends AbstractType {
* @public * @public
*/ */
toDelta (snapshot, prevSnapshot, computeYChange) { toDelta (snapshot, prevSnapshot, computeYChange) {
/** return rangeDelta(this, null, null, snapshot, prevSnapshot, computeYChange)
* @type{Array<any>}
*/
const ops = []
const currentAttributes = new Map()
const doc = /** @type {Doc} */ (this.doc)
let str = ''
let n = this._start
function packStr () {
if (str.length > 0) {
// pack str with attributes to ops
/**
* @type {Object<string,any>}
*/
const attributes = {}
let addAttributes = false
currentAttributes.forEach((value, key) => {
addAttributes = true
attributes[key] = value
})
/**
* @type {Object<string,any>}
*/
const op = { insert: str }
if (addAttributes) {
op.attributes = attributes
}
ops.push(op)
str = ''
}
}
const computeDelta = () => {
while (n !== null) {
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
switch (n.content.constructor) {
case ContentString: {
const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.type !== 'removed') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.type !== 'added') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
str += /** @type {ContentString} */ (n.content).str
break
}
case ContentType:
case ContentEmbed: {
packStr()
/**
* @type {Object<string,any>}
*/
const op = {
insert: n.content.getContent()[0]
}
if (currentAttributes.size > 0) {
const attrs = /** @type {Object<string,any>} */ ({})
op.attributes = attrs
currentAttributes.forEach((value, key) => {
attrs[key] = value
})
}
ops.push(op)
break
}
case ContentFormat:
if (isVisible(n, snapshot)) {
packStr()
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
}
break
}
}
n = n.right
}
packStr()
}
if (snapshot || prevSnapshot) {
// snapshots are merged again after the transaction, so we need to keep the
// transaction alive until we are done
transact(doc, transaction => {
if (snapshot) {
splitSnapshotAffectedStructs(transaction, snapshot)
}
if (prevSnapshot) {
splitSnapshotAffectedStructs(transaction, prevSnapshot)
}
computeDelta()
}, 'cleanup')
} else {
computeDelta()
}
return ops
} }
/** /**
@ -1159,6 +1060,29 @@ export class YText extends AbstractType {
} }
} }
/**
* Returns a WeakLink representing a dynamic quotation of a range of elements.
*
* In case when quotation happens in a middle of formatting range, formatting
* attributes will be split into before|within|after eg. quoting fragment of
* `<i>hello world</i>` could result in `<i>he</i>"<i>llo wo</i>"<i>rld</i>`
* where `"<i>llo wo</i>"` represents quoted range.
*
* @param {YRange} range
* @return {YWeakLink<string>}
*
* @public
*/
quote (range) {
if (this.doc !== null) {
return transact(this.doc, transaction => {
return quoteRange(transaction, this, range)
})
}
throw new Error('cannot quote an YText that has not been integrated into YDoc')
}
/** /**
* Deletes text starting from an index. * Deletes text starting from an index.
* *
@ -1284,6 +1208,146 @@ export class YText extends AbstractType {
} }
} }
/**
* Returns a delta representation that happens between `start` and `end` ranges (both sides inclusive).
*
* @param {AbstractType<any>} parent
* @param {ID|null} start
* @param {ID|null} end
* @param {Snapshot|undefined} snapshot
* @param {Snapshot|undefined} prevSnapshot
* @param {(function('removed' | 'added', ID):any)|undefined} computeYChange
* @returns {any} The Delta representation of this type.
*/
export const rangeDelta = (parent, start, end, snapshot, prevSnapshot, computeYChange) => {
/**
* @type{Array<any>}
*/
const ops = []
const currentAttributes = new Map()
const doc = /** @type {Doc} */ (parent.doc)
let str = ''
let n = parent._start
function packStr () {
if (str.length > 0) {
// pack str with attributes to ops
/**
* @type {Object<string,any>}
*/
const attributes = {}
let addAttributes = false
currentAttributes.forEach((value, key) => {
addAttributes = true
attributes[key] = value
})
/**
* @type {Object<string,any>}
*/
const op = { insert: str }
if (addAttributes) {
op.attributes = attributes
}
ops.push(op)
str = ''
}
}
const computeDelta = () => {
// startOffset represents offset at current block from which we're intersted in picking string
// if it's -1 it means, we're out of scope and we should break at this point
let startOffset = start === null ? 0 : -1
// eslint-disable-next-line no-labels
loop: while (n !== null) {
if (startOffset < 0 && start !== null) {
if (start.client === n.id.client && start.clock >= n.id.clock && start.clock < n.id.clock + n.length) {
startOffset = start.clock - n.id.clock
}
}
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
switch (n.content.constructor) {
case ContentString: {
const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.type !== 'removed') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.type !== 'added') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
const s = /** @type {ContentString} */ (n.content).str
if (startOffset > 0) {
str += s.slice(startOffset)
startOffset = 0
} else if (end !== null && end.client === n.id.client && end.clock >= n.id.clock && end.clock < n.id.clock + n.length) {
// we reached the end or range
const endOffset = n.id.clock + n.length - end.clock - 1
str += s.slice(0, s.length + endOffset) // scope is negative
packStr()
// eslint-disable-next-line no-labels
break loop
} else if (startOffset === 0) {
str += s
}
break
}
case ContentType:
case ContentEmbed: {
packStr()
/**
* @type {Object<string,any>}
*/
const op = {
insert: n.content.getContent()[0]
}
if (currentAttributes.size > 0) {
const attrs = /** @type {Object<string,any>} */ ({})
op.attributes = attrs
currentAttributes.forEach((value, key) => {
attrs[key] = value
})
}
ops.push(op)
break
}
case ContentFormat:
if (isVisible(n, snapshot)) {
packStr()
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
}
break
}
} else if (end !== null && end.client === n.id.client && end.clock >= n.id.clock && end.clock < n.id.clock + n.length) {
// block may not passed visibility check, but we still need to verify boundaries
break
}
n = n.right
}
packStr()
}
if (snapshot || prevSnapshot) {
// snapshots are merged again after the transaction, so we need to keep the
// transaction alive until we are done
transact(doc, transaction => {
if (snapshot) {
splitSnapshotAffectedStructs(transaction, snapshot)
}
if (prevSnapshot) {
splitSnapshotAffectedStructs(transaction, prevSnapshot)
}
computeDelta()
}, 'cleanup')
} else {
computeDelta()
}
return ops
}
/** /**
* @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder * @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
* @return {YText} * @return {YText}

463
src/types/YWeakLink.js Normal file
View File

@ -0,0 +1,463 @@
import { decoding, encoding } from 'lib0'
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import {
YEvent, AbstractType,
transact,
getItemCleanEnd,
getItemCleanStart,
callTypeObservers,
YWeakLinkRefID,
writeID,
readID,
RelativePosition,
ContentString,
rangeDelta,
formatXmlString,
YText,
YXmlText,
Transaction, Item, Doc, ID, Snapshot, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, YRange, rangeToRelative, // eslint-disable-line
} from '../internals.js'
/**
* @template T extends AbstractType<any>
* @extends YEvent<any>
* Event that describes the changes on a YMap.
*/
export class YWeakLinkEvent extends YEvent {
/**
* @param {YWeakLink<T>} ylink The YWeakLink to which this event was propagated to.
* @param {Transaction} transaction
*/
// eslint-disable-next-line no-useless-constructor
constructor (ylink, transaction) {
super(ylink, transaction)
}
}
/**
* @template T
* @extends AbstractType<YWeakLinkEvent<T>>
*
* Weak link to another value stored somewhere in the document.
*/
export class YWeakLink extends AbstractType {
/**
* @param {RelativePosition} start
* @param {RelativePosition} end
* @param {Item|null} firstItem
*/
constructor (start, end, firstItem) {
super()
/** @type {RelativePosition} */
this._quoteStart = start
/** @type {RelativePosition} */
this._quoteEnd = end
/** @type {Item|null} */
this._firstItem = firstItem
}
/**
* Position descriptor of the start of a quoted range.
*
* @returns {RelativePosition}
*/
get quoteStart () {
return this._quoteStart
}
/**
* Position descriptor of the end of a quoted range.
*
* @returns {RelativePosition}
*/
get quoteEnd () {
return this._quoteEnd
}
/**
* Check if current link contains only a single element.
*
* @returns {boolean}
*/
get isSingle () {
return this._quoteStart.item === this._quoteEnd.item
}
/**
* Returns a reference to an underlying value existing somewhere on in the document.
*
* @return {T|undefined}
*/
deref () {
if (this._firstItem !== null) {
let item = this._firstItem
if (item.parentSub !== null) {
while (item.right !== null) {
item = item.right
}
// we don't support quotations over maps
this._firstItem = item
}
if (!item.deleted) {
return item.content.getContent()[0]
}
}
return undefined
}
/**
* Returns an array of references to all elements quoted by current weak link.
*
* @return {Array<any>}
*/
unquote () {
let result = /** @type {Array<any>} */ ([])
let n = this._firstItem
if (n !== null && this._quoteStart.assoc >= 0) {
// if assoc >= we exclude start from range
n = n.right
}
const end = /** @type {ID} */ (this._quoteEnd.item)
const endAssoc = this._quoteEnd.assoc
// TODO: moved elements
while (n !== null) {
if (endAssoc < 0 && n.id.client === end.client && n.id.clock === end.clock) {
// right side is open (last item excluded)
break
}
if (!n.deleted) {
result = result.concat(n.content.getContent())
}
const lastId = n.lastId
if (endAssoc >= 0 && lastId.client === end.client && lastId.clock === end.clock) {
break
}
n = n.right
}
return result
}
/**
* 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|null} item
*/
_integrate (y, item) {
super._integrate(y, item)
if (item !== null) {
transact(y, (transaction) => {
// link may refer to a single element in multi-element block
// in such case we need to cut of the linked element into a
// separate block
let [firstItem, lastItem] = sliceBlocksByRange(transaction, this._quoteStart, this.quoteEnd)
if (firstItem.parentSub !== null) {
// for maps, advance to most recent item
while (firstItem.right !== null) {
firstItem = firstItem.right
}
}
this._firstItem = firstItem
/** @type {Item|null} */
let item = firstItem
for (;item !== null; item = item.right) {
createLink(transaction, item, this)
if (item === lastItem) {
break
}
}
})
}
}
/**
* @return {YWeakLink<T>}
*/
_copy () {
return new YWeakLink(this._quoteStart, this._quoteEnd, this._firstItem)
}
/**
* @return {YWeakLink<T>}
*/
clone () {
return new YWeakLink(this._quoteStart, this._quoteEnd, this._firstItem)
}
/**
* Creates YWeakLinkEvent and calls observers.
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
super._callObserver(transaction, parentSubs)
callTypeObservers(this, transaction, new YWeakLinkEvent(this, transaction))
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YWeakLinkRefID)
const isSingle = this.isSingle
/**
* Info flag bits:
* - 0: is quote spanning over single element?
* If this bit is set, we skip writing ID of quotation end.
* - 1: is quotation start inclusive
* - 2: is quotation end exclusive
*
* Future proposition for bits usage:
* - 3: is quotation start unbounded.
* - 4: is quotation end unbounded
* - 5: if quotation is unbounded on both ends, this bit says if quoted collection is root type.
* The next ID/String is a quoted collection ID or name.
* - 6: this quotation links to a subdocument.
* If set, the last segment of data contains info that may be needed to restore subdoc data.
* - 7: left unused. Potentially useful as a varint continuation flag if we need to expand this
* flag in the future.
*/
const info = (isSingle ? 0 : 1) | (this._quoteStart.assoc >= 0 ? 2 : 0) | (this._quoteEnd.assoc >= 0 ? 4 : 0)
encoding.writeUint8(encoder.restEncoder, info)
writeID(encoder.restEncoder, /** @type {ID} */ (this._quoteStart.item))
if (!isSingle) {
writeID(encoder.restEncoder, /** @type {ID} */ (this._quoteEnd.item))
}
}
/**
* Returns the unformatted string representation of this quoted text range.
*
* @public
*/
toString () {
let n = this._firstItem
if (n !== null && this._quoteStart.assoc >= 0) {
// if assoc >= we exclude start from range
n = n.right
}
if (n !== null) {
switch (/** @type {AbstractType<any>} */ (n.parent).constructor) {
case YText: {
let str = ''
const end = /** @type {ID} */ (this._quoteEnd.item)
const endAssoc = this._quoteEnd.assoc
while (n !== null) {
if (endAssoc < 0 && n.id.client === end.client && n.id.clock === end.clock) {
// right side is open (last item excluded)
break
}
if (!n.deleted && n.countable && n.content.constructor === ContentString) {
str += /** @type {ContentString} */ (n.content).str
}
if (endAssoc >= 0) {
const lastId = n.lastId
if (lastId.client === end.client && lastId.clock === end.clock) {
// right side is closed (last item included)
break
}
}
n = n.right
}
return str
}
case YXmlText:
return this.toDelta().map(delta => formatXmlString(delta)).join('')
}
} else {
return ''
}
}
/**
* Returns the Delta representation of quoted part of underlying text type.
*
* @param {Snapshot|undefined} [snapshot]
* @param {Snapshot|undefined} [prevSnapshot]
* @param {function('removed' | 'added', ID):any} [computeYChange]
* @returns {Array<any>}
*/
toDelta (snapshot, prevSnapshot, computeYChange) {
if (this._firstItem !== null && this._quoteStart.item !== null && this._quoteEnd.item !== null) {
const parent = /** @type {AbstractType<any>} */ (this._firstItem.parent)
return rangeDelta(parent, this._quoteStart.item, this._quoteEnd.item, snapshot, prevSnapshot, computeYChange)
} else {
return []
}
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YWeakLink<any>}
*/
export const readYWeakLink = decoder => {
const info = decoding.readUint8(decoder.restDecoder)
const isSingle = (info & 1) !== 1
const startAssoc = (info & 2) === 2 ? 0 : -1
const endAssoc = (info & 4) === 4 ? 0 : -1
const startID = readID(decoder.restDecoder)
const start = new RelativePosition(null, null, startID, startAssoc)
const end = new RelativePosition(null, null, isSingle ? startID : readID(decoder.restDecoder), endAssoc)
return new YWeakLink(start, end, null)
}
/**
* Returns a {WeakLink} to an YArray element at given index.
*
* @param {AbstractType<any>} parent
* @param {Transaction} transaction
* @param {YRange} range
* @return {YWeakLink<any>}
*/
export const quoteRange = (transaction, parent, range) => {
const [start, end] = rangeToRelative(parent, range)
const [startItem, endItem] = sliceBlocksByRange(transaction, start, end)
const link = new YWeakLink(start, end, startItem)
if (parent.doc !== null) {
transact(parent.doc, (transaction) => {
for (let item = link._firstItem; item !== null; item = item = item.right) {
createLink(transaction, item, link)
if (item === endItem) {
break
}
}
})
}
return link
}
/**
* Checks relative position markers and slices the corresponding struct store items
* across their positions.
*
* @param {Transaction} transaction
* @param {RelativePosition} start
* @param {RelativePosition} end
* @returns {Array<Item>} first and last item that belongs to a sliced range
*/
const sliceBlocksByRange = (transaction, start, end) => {
if (start.item === null || end.item === null) {
throw new Error('this operation requires range to be bounded on both sides')
}
const first = getItemCleanStart(transaction, start.item)
/** @type {Item} */
let last
if (end.assoc >= 0) {
last = getItemCleanEnd(transaction, transaction.doc.store, end.item)
} else {
const item = getItemCleanStart(transaction, end.item)
last = /** @type {Item} */ (item.left)
}
return [first, last]
}
/**
* Returns a {WeakLink} to an YMap element at given key.
*
* @param {AbstractType<any>} parent
* @param {string} key
* @return {YWeakLink<any>|undefined}
*/
export const mapWeakLink = (parent, key) => {
const item = parent._map.get(key)
if (item !== undefined) {
const start = new RelativePosition(null, null, item.id, 0)
const end = new RelativePosition(null, null, item.id, -1)
const link = new YWeakLink(start, end, item)
if (parent.doc !== null) {
transact(parent.doc, (transaction) => {
createLink(transaction, item, link)
})
}
return link
} else {
return undefined
}
}
/**
* Establishes a link between source and weak link reference.
* It assumes that source has already been split if necessary.
*
* @param {Transaction} transaction
* @param {Item} source
* @param {YWeakLink<any>} linkRef
*/
export const createLink = (transaction, source, linkRef) => {
const allLinks = transaction.doc.store.linkedBy
map.setIfUndefined(allLinks, source, set.create).add(linkRef)
source.linked = true
}
/**
* Deletes the link between source and a weak link reference.
*
* @param {Transaction} transaction
* @param {Item} source
* @param {YWeakLink<any>} linkRef
*/
export const unlinkFrom = (transaction, source, linkRef) => {
const allLinks = transaction.doc.store.linkedBy
const linkedBy = allLinks.get(source)
if (linkedBy !== undefined) {
linkedBy.delete(linkRef)
if (linkedBy.size === 0) {
allLinks.delete(source)
source.linked = false
if (source.countable) {
// since linked property is blocking items from merging,
// it may turn out that source item can be merged now
transaction._mergeStructs.push(source)
}
}
}
}
/**
* Rebinds linkedBy links pointed between neighbours of a current item.
* This method expects that current item has both left and right neighbours.
*
* @param {Transaction} transaction
* @param {Item} item
*/
export const joinLinkedRange = (transaction, item) => {
item.linked = true
const allLinks = transaction.doc.store.linkedBy
const leftLinks = allLinks.get(/** @type {Item} */ (item.left))
const rightLinks = allLinks.get(/** @type {Item} */ (item.right))
if (leftLinks && rightLinks) {
const common = new Set()
for (const link of leftLinks) {
if (rightLinks.has(link)) {
// new item existing in a quoted range in between two elements
common.add(link)
} else if (link._quoteEnd.assoc < 0) {
// We're at the right edge of quoted range - right neighbor is not included
// but the left one is. Since quotation is open on the right side, we need to
// include current item.
common.add(link)
}
}
for (const link of rightLinks) {
if (!leftLinks.has(link) && link._firstItem === item.left && link._quoteStart.assoc >= 0) {
// We're at the right edge of quoted range - right neighbor is not included
// but the left one is. Since quotation is open on the right side, we need to
// include current item.
link._firstItem = item // this item is the new most left-wise
common.add(link)
}
}
if (common.size !== 0) {
allLinks.set(item, common)
}
}
}

View File

@ -67,36 +67,7 @@ export class YXmlText extends YText {
toString () { toString () {
// @ts-ignore // @ts-ignore
return this.toDelta().map(delta => { return this.toDelta().map(delta => formatXmlString(delta)).join('')
const nestedNodes = []
for (const nodeName in delta.attributes) {
const attrs = []
for (const key in delta.attributes[nodeName]) {
attrs.push({ key, value: delta.attributes[nodeName][key] })
}
// sort attributes to get a unique order
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
nestedNodes.push({ nodeName, attrs })
}
// sort node order to get a unique order
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
// now convert to dom string
let str = ''
for (let i = 0; i < nestedNodes.length; i++) {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[j]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'
}
str += delta.insert
for (let i = nestedNodes.length - 1; i >= 0; i--) {
str += `</${nestedNodes[i].nodeName}>`
}
return str
}).join('')
} }
/** /**
@ -114,6 +85,43 @@ export class YXmlText extends YText {
} }
} }
/**
* Formats individual delta segment provided by `Text.toDelta` into XML-formatted string.
*
* @param {any} delta
* @returns {string}
*/
export const formatXmlString = (delta) => {
const nestedNodes = []
for (const nodeName in delta.attributes) {
const attrs = []
for (const key in delta.attributes[nodeName]) {
attrs.push({ key, value: delta.attributes[nodeName][key] })
}
// sort attributes to get a unique order
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
nestedNodes.push({ nodeName, attrs })
}
// sort node order to get a unique order
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
// now convert to dom string
let str = ''
for (let i = 0; i < nestedNodes.length; i++) {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[j]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'
}
str += delta.insert
for (let i = nestedNodes.length - 1; i >= 0; i--) {
str += `</${nestedNodes[i].nodeName}>`
}
return str
}
/** /**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YXmlText} * @return {YXmlText}

View File

@ -1,7 +1,7 @@
import { import {
GC, GC,
splitItem, splitItem,
Transaction, ID, Item, DSDecoderV2 // eslint-disable-line Transaction, ID, Item, DSDecoderV2, YWeakLink // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as math from 'lib0/math' import * as math from 'lib0/math'
@ -13,6 +13,13 @@ export class StructStore {
* @type {Map<number,Array<GC|Item>>} * @type {Map<number,Array<GC|Item>>}
*/ */
this.clients = new Map() this.clients = new Map()
/**
* If this item was referenced by other weak links, here we keep the references
* to these weak refs.
*
* @type {Map<Item, Set<YWeakLink<any>>>}
*/
this.linkedBy = new Map()
/** /**
* @type {null | { missing: Map<number, number>, update: Uint8Array }} * @type {null | { missing: Map<number, number>, update: Uint8Array }}
*/ */

214
src/utils/YRange.js Normal file
View File

@ -0,0 +1,214 @@
import {
createID,
findMarker,
createRelativePosition,
AbstractType, RelativePosition, Item // eslint-disable-line
} from '../internals.js'
/**
* Object which describes bounded range of elements, together with inclusivity/exclusivity rules
* operating over that range.
*
* These inclusivity rules bear extra meaning when it comes to concurrent inserts, that may
* eventually happen ie. range `[1..2]` (both side inclusive) means that if a concurrent insert
* would happen at the boundary between 2nd and 3rd index, it should **NOT** be a part of that
* range, while range definition `[1..3)` (right side is open) while still describing similar
* range in linear collection, would also span the range over the elements inserted concurrently
* between 2nd and 3rd indexes.
*/
export class YRange {
// API mirrored after: https://www.w3.org/TR/IndexedDB/#idbkeyrange
/**
*
* @param {number|null} lower a lower bound of a range (cannot be higher than upper)
* @param {number|null} upper an upper bound of a range (cannot be less than lower)
* @param {boolean} lowerOpen if `true` lower is NOT included in the range
* @param {boolean} upperOpen if `true` upper is NOT included in the range
*/
constructor (lower, upper, lowerOpen = false, upperOpen = false) {
if (lower !== null && upper !== null && lower > upper) {
throw new Error('Invalid range: lower bound is higher than upper bound')
}
/**
* A lower bound of a range (cannot be higher than upper). Null if unbounded.
* @type {number|null}
*/
this.lower = lower
/**
* An upper bound of a range (cannot be less than lower). Null if unbounded.
* @type {number|null}
*/
this.upper = upper
/**
* If `true` lower is NOT included in the range.
* @type {boolean}
*/
this.lowerOpen = lowerOpen
/**
* If `true` upper is NOT included in the range.
* @type {boolean}
*/
this.upperOpen = upperOpen
}
/**
* Creates a range that only spans over a single element.
*
* @param {number} index
* @returns {YRange}
*/
static only (index) {
return new YRange(index, index)
}
/**
* Returns a range instance, that's bounded on the lower side and
* unbounded on the upper side.
*
* @param {number} lower a lower bound of a range
* @param {boolean} lowerOpen if `true` lower is NOT included in the range
* @returns {YRange}
*/
static lowerBound (lower, lowerOpen = false) {
return new YRange(lower, null, lowerOpen, false)
}
/**
* Returns a range instance, that's unbounded on the lower side and
* bounded on the upper side.
*
* @param {number} upper an upper bound of a range
* @param {boolean} upperOpen if `true` upper is NOT included in the range
* @returns {YRange}
*/
static upperBound (upper, upperOpen = false) {
return new YRange(null, upper, false, upperOpen)
}
/**
* Creates a new range instance, bounded on both ends.
*
* @param {number} lower a lower bound of a range (cannot be higher than upper)
* @param {number} upper an upper bound of a range (cannot be less than lower)
* @param {boolean} lowerOpen if `true` lower is NOT included in the range
* @param {boolean} upperOpen if `true` upper is NOT included in the range
*/
static bound (lower, upper, lowerOpen = false, upperOpen = false) {
return new YRange(lower, upper, lowerOpen, upperOpen)
}
/**
* Checks if a provided index is included in current range.
*
* @param {number} index
* @returns {boolean}
*/
includes (index) {
if (this.lower !== null && index < this.lower) {
return false
}
if (this.upper !== null && index > this.upper) {
return false
}
if (index === this.lower) {
return !this.lowerOpen
}
if (index === this.upper) {
return !this.upperOpen
}
return true
}
}
const indexOutOfBounds = new Error('index out of bounds')
/**
*
* @param {AbstractType<any>} type
* @param {number} index
* @returns {{item: Item,index:number}|null}
*/
const findPosition = (type, index) => {
if (type._searchMarker !== null) {
const marker = findMarker(type, index)
if (marker !== null) {
return { item: marker.p, index: marker.index }
} else {
return null
}
} else {
let remaining = index
let item = type._start
for (; item !== null && remaining > 0; item = item.right) {
if (!item.deleted && item.countable) {
if (remaining < item.length) {
break
}
remaining -= item.length
}
}
if (item === null) {
return null
} else {
return { item, index: index - remaining }
}
}
}
/**
* Returns a pair of values representing relative IDs of a range.
*
* @param {AbstractType<any>} type collection that range relates to
* @param {YRange} range
* @returns {RelativePosition[]}
* @throws Will throw an error, if range indexes are out of an type's bounds.
*/
export const rangeToRelative = (type, range) => {
/** @type {RelativePosition} */
let start
/** @type {RelativePosition} */
let end
let item = type._start
let remaining = 0
if (range.lower !== null) {
remaining = range.lower
if (remaining === 0 && item !== null) {
start = createRelativePosition(type, item.id, range.lowerOpen ? 0 : -1)
} else {
const pos = findPosition(type, remaining)
if (pos !== null) {
item = pos.item
remaining -= pos.index
start = createRelativePosition(type, createID(pos.item.id.client, pos.item.id.clock + remaining), range.lowerOpen ? 0 : -1)
} else {
throw indexOutOfBounds
}
}
} else {
// left-side unbounded
start = createRelativePosition(type, null, -1)
}
if (range.upper !== null) {
remaining = range.upper - (range.lower ?? 0) + remaining
while (item !== null) {
if (!item.deleted && item.countable) {
if (item.length > remaining) {
break
}
remaining -= item.length
}
item = item.right
}
if (item === null) {
throw indexOutOfBounds
} else {
end = createRelativePosition(type, createID(item.id.client, item.id.clock + remaining), range.upperOpen ? -1 : 0)
}
} else {
// right-side unbounded
end = createRelativePosition(type, null, 0)
}
return [start, end]
}

View File

@ -4,6 +4,7 @@ import * as map from './y-map.tests.js'
import * as array from './y-array.tests.js' import * as array from './y-array.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 weak from './y-weak-link.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 compatibility from './compatibility.tests.js' import * as compatibility from './compatibility.tests.js'
@ -20,7 +21,7 @@ if (isBrowser) {
log.createVConsole(document.body) log.createVConsole(document.body)
} }
runTests({ runTests({
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions doc, map, array, text, xml, weak, encoding, undoredo, compatibility, snapshot, updates, relativePositions
}).then(success => { }).then(success => {
/* istanbul ignore next */ /* istanbul ignore next */
if (isNode) { if (isNode) {

873
tests/y-weak-link.tests.js Normal file
View File

@ -0,0 +1,873 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import { init } from './testHelper.js'
/**
* @param {t.TestCase} tc
*/
export const testBasicMap = tc => {
const doc = new Y.Doc()
const map = doc.getMap('map')
const nested = new Y.Map()
nested.set('a1', 'hello')
map.set('a', nested)
const link = map.link('a')
map.set('b', link)
const link2 = /** @type {Y.WeakLink<any>} */ (map.get('b'))
const expected = nested.toJSON()
const actual = link2.deref().toJSON()
t.compare(actual, expected)
}
/**
* @param {t.TestCase} tc
*/
export const testBasicArray = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, [1, 2, 3])
array0.insert(3, [array0.quote(Y.Range.only(1))])
t.compare(array0.get(0), 1)
t.compare(array0.get(1), 2)
t.compare(array0.get(2), 3)
t.compare(array0.get(3).deref(), 2)
testConnector.flushAllMessages()
t.compare(array1.get(0), 1)
t.compare(array1.get(1), 2)
t.compare(array1.get(2), 3)
t.compare(array1.get(3).deref(), 2)
}
/**
* @param {t.TestCase} tc
*/
export const testArrayQuoteMultipleElements = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 2 })
const nested = new Y.Map([['key', 'value']])
array0.insert(0, [1, 2, nested, 3])
array0.insert(0, [array0.quote(Y.Range.bound(1, 3))])
const link0 = array0.get(0)
t.compare(link0.unquote(), [2, nested, 3])
t.compare(array0.get(1), 1)
t.compare(array0.get(2), 2)
t.compare(array0.get(3), nested)
t.compare(array0.get(4), 3)
testConnector.flushAllMessages()
const link1 = array1.get(0)
let unquoted = link1.unquote()
t.compare(unquoted[0], 2)
t.compare(unquoted[1].toJSON(), { key: 'value' })
t.compare(unquoted[2], 3)
t.compare(array1.get(1), 1)
t.compare(array1.get(2), 2)
t.compare(array1.get(3).toJSON(), { key: 'value' })
t.compare(array1.get(4), 3)
array1.insert(3, ['A', 'B'])
unquoted = link1.unquote()
t.compare(unquoted[0], 2)
t.compare(unquoted[1], 'A')
t.compare(unquoted[2], 'B')
t.compare(unquoted[3].toJSON(), { key: 'value' })
t.compare(unquoted[4], 3)
testConnector.flushAllMessages()
t.compare(array0.get(0).unquote(), [2, 'A', 'B', nested, 3])
}
/**
* @param {t.TestCase} tc
*/
export const testSelfQuotation = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, [1, 2, 3, 4])
const link0 = array0.quote(Y.Range.bound(0, 3, false, true))
array0.insert(1, [link0]) // link is inserted into its own range
t.compare(link0.unquote(), [1, link0, 2, 3])
t.compare(array0.get(0), 1)
t.compare(array0.get(1), link0)
t.compare(array0.get(2), 2)
t.compare(array0.get(3), 3)
t.compare(array0.get(4), 4)
testConnector.flushAllMessages()
const link1 = array1.get(1)
const unquoted = link1.unquote()
t.compare(unquoted, [1, link1, 2, 3])
t.compare(array1.get(0), 1)
t.compare(array1.get(1), link1)
t.compare(array1.get(2), 2)
t.compare(array1.get(3), 3)
t.compare(array1.get(4), 4)
}
/**
* @param {t.TestCase} tc
*/
export const testUpdate = tc => {
const { testConnector, map0, map1 } = init(tc, { users: 2 })
map0.set('a', new Y.Map([['a1', 'hello']]))
const link0 = /** @type {Y.WeakLink<Y.Map<any>>} */ (map0.link('a'))
map0.set('b', link0)
testConnector.flushAllMessages()
const link1 = /** @type {Y.WeakLink<Y.Map<any>>} */ (map1.get('b'))
let l1 = /** @type {Y.Map<any>} */ (link1.deref())
let l0 = /** @type {Y.Map<any>} */ (link0.deref())
t.compare(l1.get('a1'), l0.get('a1'))
map1.get('a').set('a2', 'world')
testConnector.flushAllMessages()
l1 = /** @type {Y.Map<any>} */ (link1.deref())
l0 = /** @type {Y.Map<any>} */ (link0.deref())
t.compare(l1.get('a2'), l0.get('a2'))
}
/**
* @param {t.TestCase} tc
*/
export const testDeleteWeakLink = tc => {
const { testConnector, map0, map1 } = init(tc, { users: 2 })
map0.set('a', new Y.Map([['a1', 'hello']]))
const link0 = /** @type {Y.WeakLink<Y.Map<any>>} */ (map0.link('a'))
map0.set('b', link0)
testConnector.flushAllMessages()
const link1 = /** @type {Y.WeakLink<Y.Map>} */ map1.get('b')
const l1 = /** @type {Y.Map<any>} */ (link1.deref())
const l0 = /** @type {Y.Map<any>} */ (link0.deref())
t.compare(l1.get('a1'), l0.get('a1'))
map1.delete('b') // delete links
testConnector.flushAllMessages()
// since links have been deleted, they no longer refer to any content
t.compare(link0.deref(), undefined)
t.compare(link1.deref(), undefined)
}
/**
* @param {t.TestCase} tc
*/
export const testDeleteSource = tc => {
const { testConnector, map0, map1 } = init(tc, { users: 2 })
map0.set('a', new Y.Map([['a1', 'hello']]))
const link0 = /** @type {Y.WeakLink<Y.Map<any>>} */ (map0.link('a'))
map0.set('b', link0)
testConnector.flushAllMessages()
const link1 = /** @type {Y.WeakLink<Y.Map<any>>} */ (map1.get('b'))
const l1 = /** @type {Y.Map<any>} */ (link1.deref())
const l0 = /** @type {Y.Map<any>} */ (link0.deref())
t.compare(l1.get('a1'), l0.get('a1'))
map1.delete('a') // delete source of the link
testConnector.flushAllMessages()
// since source have been deleted, links no longer refer to any content
t.compare(link0.deref(), undefined)
t.compare(link1.deref(), undefined)
}
/**
* @param {t.TestCase} tc
*/
export const testObserveMapUpdate = tc => {
const { testConnector, map0, map1 } = init(tc, { users: 2 })
map0.set('a', 'value')
const link0 = /** @type {Y.WeakLink<String>} */ (map0.link('a'))
map0.set('b', link0)
/**
* @type {any}
*/
let target0
link0.observe((e) => {
target0 = e.target
})
testConnector.flushAllMessages()
const link1 = /** @type {Y.WeakLink<String>} */ (map1.get('b'))
t.compare(link1.deref(), 'value')
/**
* @type {any}
*/
let target1
link1.observe((e) => {
target1 = e.target
})
map0.set('a', 'value2')
t.compare(target0.deref(), 'value2')
testConnector.flushAllMessages()
t.compare(target1.deref(), 'value2')
}
/**
* @param {t.TestCase} tc
*/
export const testObserveMapDelete = tc => {
const { testConnector, map0, map1 } = init(tc, { users: 2 })
map0.set('a', 'value')
const link0 = /** @type {Y.WeakLink<String>} */ (map0.link('a'))
map0.set('b', link0)
/**
* @type {any}
*/
let target0
link0.observe((e) => {
target0 = e.target
})
testConnector.flushAllMessages()
const link1 = /** @type {Y.WeakLink<String>} */ (map1.get('b'))
t.compare(link1.deref(), 'value')
/**
* @type {any}
*/
let target1
link1.observe((e) => {
target1 = e.target
})
map0.delete('a')
t.compare(target0.deref(), undefined)
testConnector.flushAllMessages()
t.compare(target1.deref(), undefined)
}
/**
* @param {t.TestCase} tc
*/
export const testObserveArray = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, ['A', 'B', 'C'])
const link0 = /** @type {Y.WeakLink<String>} */ (array0.quote(Y.Range.bound(1, 2)))
array0.insert(0, [link0])
/**
* @type {any}
*/
let target0
link0.observe((e) => {
target0 = e.target
})
testConnector.flushAllMessages()
const link1 = /** @type {Y.WeakLink<String>} */ (array1.get(0))
t.compare(link1.unquote(), ['B', 'C'])
/**
* @type {any}
*/
let target1
link1.observe((e) => {
target1 = e.target
})
array0.delete(2)
t.compare(target0.unquote(), ['C'])
testConnector.flushAllMessages()
t.compare(target1.unquote(), ['C'])
array1.delete(2)
t.compare(target1.unquote(), [])
testConnector.flushAllMessages()
t.compare(target0.unquote(), [])
target0 = null
array0.delete(1)
t.compare(target0, null)
}
/**
* @param {t.TestCase} tc
*/
export const testDeepObserveTransitive = tc => {
// test observers in a face of linked chains of values
const doc = new Y.Doc()
/*
Structure:
- map1
- link-key: <=+-+
- map2: | |
- key: value1-+ |
- link-link: <--+
*/
const map1 = doc.getMap('map1')
const map2 = doc.getMap('map2')
map2.set('key', 'value1')
const link1 = /** @type {Y.WeakLink<String>} */ (map2.link('key'))
map1.set('link-key', link1)
const link2 = /** @type {Y.WeakLink<String>} */ (map1.link('link-key'))
map2.set('link-link', link2)
/**
* @type {Array<any>}
*/
let events = []
link2.observeDeep((e) => {
events = e
})
map2.set('key', 'value2')
const values = events.map((e) => e.target.deref())
t.compare(values, ['value2'])
}
/**
* @param {t.TestCase} tc
*/
export const testDeepObserveTransitive2 = tc => {
// test observers in a face of multi-layer linked chains of values
const doc = new Y.Doc()
/*
Structure:
- map1
- link-key: <=+-+
- map2: | |
- key: value1-+ |
- link-link: <==+--+
- map3: |
- link-link-link:<-+
*/
const map1 = doc.getMap('map1')
const map2 = doc.getMap('map2')
const map3 = doc.getMap('map3')
map2.set('key', 'value1')
const link1 = /** @type {Y.WeakLink<String>} */ (map2.link('key'))
map1.set('link-key', link1)
const link2 = /** @type {Y.WeakLink<String>} */ (map1.link('link-key'))
map2.set('link-link', link2)
const link3 = /** @type {Y.WeakLink<String>} */ (map2.link('link-link'))
map3.set('link-link-link', link3)
/**
* @type {Array<any>}
*/
let events = []
link3.observeDeep((e) => {
events = e
})
map2.set('key', 'value2')
const values = events.map((e) => e.target.deref())
t.compare(values, ['value2'])
}
/**
* @param {t.TestCase} tc
*/
export const testDeepObserveMap = tc => {
// test observers in a face of linked chains of values
const doc = new Y.Doc()
/*
Structure:
- map (observed):
- link:<----+
- array: |
0: nested:-+
- key: value
*/
const map = doc.getMap('map')
const array = doc.getArray('array')
/**
* @type {Array<any>}
*/
let events = []
map.observeDeep((es) => {
events = es.map((e) => {
return { target: e.target, keys: e.keys }
})
})
const nested = new Y.Map()
array.insert(0, [nested])
const link = array.quote(Y.Range.only(0))
map.set('link', link)
// update entry in linked map
events = []
nested.set('key', 'value')
t.compare(events.length, 1)
t.compare(events[0].target, nested)
t.compare(events[0].keys, new Map([['key', { action: 'add', oldValue: undefined }]]))
// delete entry in linked map
events = []
nested.delete('key')
t.compare(events.length, 1)
t.compare(events[0].target, nested)
t.compare(events[0].keys, new Map([['key', { action: 'delete', oldValue: 'value' }]]))
// delete linked map
array.delete(0)
t.compare(events.length, 1)
t.compare(events[0].target, link)
}
/**
* @param {t.TestCase} tc
*/
export const testDeepObserveArray = tc => { // FIXME
// test observers in a face of linked chains of values
const doc = new Y.Doc()
/*
Structure:
- map:
- nested: --------+
- key: value |
- array (observed): |
0: <--------------+
*/
const map = doc.getMap('map')
const array = doc.getArray('array')
const nested = new Y.Map()
map.set('nested', nested)
const link = map.link('nested')
array.insert(0, [link])
/**
* @type {Array<any>}
*/
let events = []
array.observeDeep((evts) => {
events = []
for (const e of evts) {
switch (e.constructor) {
case Y.YMapEvent:
events.push({ target: e.target, keys: e.keys })
break
case Y.YWeakLinkEvent:
events.push({ target: e.target })
break
default: throw new Error('unexpected event type ' + e.constructor)
}
}
})
// update entry in linked map
events = []
nested.set('key', 'value')
t.compare(events.length, 1)
t.compare(events[0].target, nested)
t.compare(events[0].keys, new Map([['key', { action: 'add', oldValue: undefined }]]))
nested.set('key', 'value2')
t.compare(events.length, 1)
t.compare(events[0].target, nested)
t.compare(events[0].keys, new Map([['key', { action: 'update', oldValue: 'value' }]]))
// delete entry in linked map
nested.delete('key')
t.compare(events.length, 1)
t.compare(events[0].target, nested)
t.compare(events[0].keys, new Map([['key', { action: 'delete', oldValue: 'value2' }]]))
// delete linked map
map.delete('nested')
t.compare(events.length, 1)
t.compare(events[0].target, link)
}
/**
* @param {t.TestCase} tc
*/
export const testDeepObserveNewElementWithinQuotedRange = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 2 })
const m1 = new Y.Map()
const m3 = new Y.Map()
array0.insert(0, [1, m1, m3, 2])
const link0 = array0.quote(Y.Range.bound(1, 2))
array0.insert(0, [link0])
testConnector.flushAllMessages()
/**
* @type {Array<any>}
*/
let e0 = []
link0.observeDeep((evts) => {
e0 = []
for (const e of evts) {
switch (e.constructor) {
case Y.YMapEvent:
e0.push({ target: e.target, keys: e.keys })
break
case Y.YWeakLinkEvent:
e0.push({ target: e.target })
break
default: throw new Error('unexpected event type ' + e.constructor)
}
}
})
const link1 = /** @type {Y.WeakLink<any>} */ (array1.get(0))
/**
* @type {Array<any>}
*/
let e1 = []
link1.observeDeep((evts) => {
e1 = []
for (const e of evts) {
switch (e.constructor) {
case Y.YMapEvent:
e1.push({ target: e.target, keys: e.keys })
break
case Y.YWeakLinkEvent:
e1.push({ target: e.target })
break
default: throw new Error('unexpected event type ' + e.constructor)
}
}
})
const m20 = new Y.Map()
array0.insert(3, [m20])
m20.set('key', 'value')
t.compare(e0.length, 1)
t.compare(e0[0].target, m20)
t.compare(e0[0].keys, new Map([['key', { action: 'add', oldValue: undefined }]]))
testConnector.flushAllMessages()
const m21 = array1.get(3)
t.compare(e1.length, 1)
t.compare(e1[0].target, m21)
t.compare(e1[0].keys, new Map([['key', { action: 'add', oldValue: undefined }]]))
}
/**
* @param {t.TestCase} tc
*/
export const testMapDeepObserve = tc => { // FIXME
const doc = new Y.Doc()
const outer = doc.getMap('outer')
const inner = new Y.Map()
outer.set('inner', inner)
/**
* @type {Array<any>}
*/
let events = []
outer.observeDeep((evts) => {
events = []
for (const e of evts) {
switch (e.constructor) {
case Y.YMapEvent:
events.push({ target: e.target, keys: e.keys })
break
case Y.YWeakLinkEvent:
events.push({ target: e.target })
break
default: throw new Error('unexpected event type ' + e.constructor)
}
}
})
inner.set('key', 'value1')
t.compare(events.length, 1)
t.compare(events[0].target, inner)
t.compare(events[0].keys, new Map([['key', { action: 'add', oldValue: undefined }]]))
events = []
inner.set('key', 'value2')
t.compare(events.length, 1)
t.compare(events[0].target, inner)
t.compare(events[0].keys, new Map([['key', { action: 'update', oldValue: 'value1' }]]))
events = []
inner.delete('key')
t.compare(events.length, 1)
t.compare(events[0].target, inner)
t.compare(events[0].keys, new Map([['key', { action: 'delete', oldValue: 'value2' }]]))
}
/**
* @param {t.TestCase} tc
*/
export const testDeepObserveRecursive = tc => {
// test observers in a face of cycled chains of values
const doc = new Y.Doc()
/*
Structure:
array (observed):
m0:--------+
- k1:<-+ |
| |
m1------+ |
- k2:<-+ |
| |
m2------+ |
- k0:<----+
*/
const root = doc.getArray('array')
const m0 = new Y.Map()
const m1 = new Y.Map()
const m2 = new Y.Map()
root.insert(0, [m0])
root.insert(1, [m1])
root.insert(2, [m2])
const l0 = root.quote(Y.Range.only(0))
const l1 = root.quote(Y.Range.only(1))
const l2 = root.quote(Y.Range.only(2))
// create cyclic reference between links
m0.set('k1', l1)
m1.set('k2', l2)
m2.set('k0', l0)
/**
* @type {Array<any>}
*/
let events = []
m0.observeDeep((es) => {
events = es.map((e) => {
return { target: e.target, keys: e.keys }
})
})
m1.set('test-key1', 'value1')
t.compare(events.length, 1)
t.compare(events[0].target, m1)
t.compare(events[0].keys, new Map([['test-key1', { action: 'add', oldValue: undefined }]]))
events = []
m2.set('test-key2', 'value2')
t.compare(events.length, 1)
t.compare(events[0].target, m2)
t.compare(events[0].keys, new Map([['test-key2', { action: 'add', oldValue: undefined }]]))
m1.delete('test-key1')
t.compare(events.length, 1)
t.compare(events[0].target, m1)
t.compare(events[0].keys, new Map([['test-key1', { action: 'delete', oldValue: 'value1' }]]))
}
/**
* @param {t.TestCase} tc
*/
export const testRemoteMapUpdate = tc => {
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
map0.set('key', 1)
testConnector.flushAllMessages()
map1.set('link', map1.link('key'))
map0.set('key', 2)
map0.set('key', 3)
// apply updated content first, link second
Y.applyUpdate(users[2], Y.encodeStateAsUpdate(users[0]))
Y.applyUpdate(users[2], Y.encodeStateAsUpdate(users[1]))
// make sure that link can find the most recent block
const link2 = map2.get('link')
t.compare(link2.deref(), 3)
testConnector.flushAllMessages()
const link1 = map1.get('link')
const link0 = map0.get('link')
t.compare(link0.deref(), 3)
t.compare(link1.deref(), 3)
t.compare(link2.deref(), 3)
}
/**
* @param {t.TestCase} tc
*/
export const testTextBasic = tc => {
const { testConnector, text0, text1 } = init(tc, { users: 2 })
text0.insert(0, 'abcd') // 'abcd'
const link0 = text0.quote(Y.Range.bound(1, 2)) // quote: [bc]
t.compare(link0.toString(), 'bc')
text0.insert(2, 'ef') // 'abefcd', quote: [befc]
t.compare(link0.toString(), 'befc')
text0.delete(3, 3) // 'abe', quote: [be]
t.compare(link0.toString(), 'be')
text0.insertEmbed(3, link0) // 'abe[be]'
testConnector.flushAllMessages()
const delta = text1.toDelta()
const { insert } = delta[1] // YWeakLink
t.compare(insert.toString(), 'be')
}
/**
* @param {t.TestCase} tc
*/
export const testXmlTextBasic = tc => {
const { testConnector, xml0, xml1 } = init(tc, { users: 2 })
const text0 = new Y.XmlText()
xml0.insert(0, [text0])
text0.insert(0, 'abcd') // 'abcd'
const link0 = text0.quote(Y.Range.bound(1, 2)) // quote: [bc]
t.compare(link0.toString(), 'bc')
text0.insert(2, 'ef') // 'abefcd', quote: [befc]
t.compare(link0.toString(), 'befc')
text0.delete(3, 3) // 'abe', quote: [be]
t.compare(link0.toString(), 'be')
text0.insertEmbed(3, link0) // 'abe[be]'
testConnector.flushAllMessages()
const text1 = /** @type {Y.XmlText} */ (xml1.get(0))
const delta = text1.toDelta()
const { insert } = delta[1] // YWeakLink
t.compare(insert.toString(), 'be')
}
/**
* @param {t.TestCase} tc
*/
export const testQuoteFormattedText = tc => {
const doc = new Y.Doc()
const text = /** @type {Y.XmlText} */ (doc.get('text', Y.XmlText))
const text2 = /** @type {Y.XmlText} */ (doc.get('text2', Y.XmlText))
text.insert(0, 'abcde')
text.format(0, 1, { b: true })
text.format(1, 3, { i: true }) // '<b>a</b><i>bcd</i>e'
const l1 = text.quote(Y.Range.bound(0, 1))
t.compare(l1.toString(), '<b>a</b><i>b</i>')
const l2 = text.quote(Y.Range.only(2)) // '<i>c</i>'
t.compare(l2.toString(), '<i>c</i>')
const l3 = text.quote(Y.Range.bound(3, 4)) // '<i>d</i>e'
t.compare(l3.toString(), '<i>d</i>e')
text2.insertEmbed(0, l1)
text2.insertEmbed(1, l2)
text2.insertEmbed(2, l3)
const delta = text2.toDelta()
t.compare(delta, [
{ insert: l1 },
{ insert: l2 },
{ insert: l3 }
])
}
/**
* @param {t.TestCase} tc
*/
export const testTextLowerBoundary = tc => {
const { testConnector, text0, text1, array0 } = init(tc, { users: 2 })
text0.insert(0, 'abcdef')
testConnector.flushAllMessages()
const linkInclusive = text0.quote(Y.Range.bound(1, 4, false, false)) // [1..4]
const linkExclusive = text0.quote(Y.Range.bound(0, 4, true, false)) // (0..4]
array0.insert(0, [linkInclusive, linkExclusive])
t.compare(linkInclusive.toString(), 'bcde')
t.compare(linkExclusive.toString(), 'bcde')
text1.insert(1, 'xyz')
testConnector.flushAllMessages()
t.compare(linkInclusive.toString(), 'bcde')
t.compare(linkExclusive.toString(), 'xyzbcde')
}
/**
* @param {t.TestCase} tc
*/
export const testTextUpperBoundary = tc => {
const { testConnector, text0, text1, array0 } = init(tc, { users: 2 })
text0.insert(0, 'abcdef')
testConnector.flushAllMessages()
const linkInclusive = text0.quote(Y.Range.bound(1, 4, false, false)) // [1..4]
const linkExclusive = text0.quote(Y.Range.bound(1, 5, false, true)) // [1..5)
array0.insert(0, [linkInclusive, linkExclusive])
t.compare(linkInclusive.toString(), 'bcde')
t.compare(linkExclusive.toString(), 'bcde')
text1.insert(5, 'xyz')
testConnector.flushAllMessages()
t.compare(linkInclusive.toString(), 'bcde')
t.compare(linkExclusive.toString(), 'bcdexyz')
}
/**
* @param {t.TestCase} tc
*/
export const testArrayLowerBoundary = tc => {
const { testConnector, array0, array1, map0 } = init(tc, { users: 2 })
array0.insert(0, ['a', 'b', 'c', 'd', 'e', 'f'])
testConnector.flushAllMessages()
const linkInclusive = array0.quote(Y.Range.bound(1, 4, false, false)) // [1..4]
const linkExclusive = array0.quote(Y.Range.bound(0, 4, true, false)) // (0..4]
map0.set('inclusive', linkInclusive)
map0.set('exclusive', linkExclusive)
t.compare(linkInclusive.unquote(), ['b', 'c', 'd', 'e'])
t.compare(linkExclusive.unquote(), ['b', 'c', 'd', 'e'])
array1.insert(1, ['x', 'y', 'z'])
testConnector.flushAllMessages()
t.compare(linkInclusive.unquote(), ['b', 'c', 'd', 'e'])
t.compare(linkExclusive.unquote(), ['x', 'y', 'z', 'b', 'c', 'd', 'e'])
}
/**
* @param {t.TestCase} tc
*/
export const testArrayUpperBoundary = tc => {
const { testConnector, array0, array1, map0 } = init(tc, { users: 2 })
array0.insert(0, ['a', 'b', 'c', 'd', 'e', 'f'])
testConnector.flushAllMessages()
const linkInclusive = array0.quote(Y.Range.bound(1, 4, false, false)) // [1..4]
const linkExclusive = array0.quote(Y.Range.bound(1, 5, false, true)) // [1..5)
map0.set('inclusive', linkInclusive)
map0.set('exclusive', linkExclusive)
t.compare(linkInclusive.unquote(), ['b', 'c', 'd', 'e'])
t.compare(linkExclusive.unquote(), ['b', 'c', 'd', 'e'])
array1.insert(5, ['x', 'y', 'z'])
testConnector.flushAllMessages()
t.compare(linkInclusive.unquote(), ['b', 'c', 'd', 'e'])
t.compare(linkExclusive.unquote(), ['b', 'c', 'd', 'e', 'x', 'y', 'z'])
}