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,
YXmlElement as XmlElement,
YXmlFragment as XmlFragment,
YWeakLink as WeakLink,
YWeakLinkEvent,
YXmlEvent,
YMapEvent,
YArrayEvent,
YTextEvent,
YEvent,
YRange as Range,
Item,
AbstractStruct,
GC,

View File

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

View File

@ -6,7 +6,10 @@ import {
readYXmlFragment,
readYXmlHook,
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'
import * as error from 'lib0/error'
@ -22,7 +25,8 @@ export const typeRefs = [
readYXmlElement,
readYXmlFragment,
readYXmlHook,
readYXmlText
readYXmlText,
readYWeakLink
]
export const YArrayRefID = 0
@ -32,6 +36,7 @@ export const YXmlElementRefID = 3
export const YXmlFragmentRefID = 4
export const YXmlHookRefID = 5
export const YXmlTextRefID = 6
export const YWeakLinkRefID = 7
/**
* @private
@ -103,6 +108,22 @@ export class ContentType {
* @param {Transaction} 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
while (item !== null) {
if (!item.deleted) {

View File

@ -22,7 +22,9 @@ import {
readContentType,
addChangedTypeToTransaction,
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'
import * as error from 'lib0/error'
@ -104,6 +106,14 @@ export const splitItem = (transaction, leftItem, diff) => {
if (leftItem.redone !== null) {
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)
leftItem.right = rightItem
// update right
@ -303,11 +313,28 @@ export class Item extends AbstractStruct {
* bit2: countable
* bit3: deleted
* bit4: mark - mark node as fast-search-marker
* bit9: linked - this item is linked by Weak Link references
* @type {number} byte
*/
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
*
@ -376,6 +403,20 @@ export class Item extends AbstractStruct {
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
if (this.origin) {
@ -507,18 +548,43 @@ export class Item extends AbstractStruct {
// set as current parent value if right === null and this is parentSub
/** @type {AbstractType<any>} */ (this.parent)._map.set(this.parentSub, this)
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.left.delete(transaction)
}
}
// adjust length of parent
if (this.parentSub === null && this.countable && !this.deleted) {
/** @type {AbstractType<any>} */ (this.parent)._length += this.length
if (this.parentSub === null && !this.deleted) {
if (this.countable) {
// 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)
this.content.integrate(transaction, this)
// add parent to transaction.changed
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)) {
// delete if parent is deleted or if this is not the current attribute value of parent
this.delete(transaction)
@ -576,6 +642,7 @@ export class Item extends AbstractStruct {
this.deleted === right.deleted &&
this.redone === null &&
right.redone === null &&
!this.linked && !right.linked && // linked items cannot be merged
this.content.constructor === right.content.constructor &&
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)
addChangedTypeToTransaction(transaction, parent, this.parentSub)
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,
ContentBinary,
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'
import * as map from 'lib0/map'
@ -232,8 +232,9 @@ export const getTypeChildren = t => {
* @param {AbstractType<EventType>} type
* @param {Transaction} transaction
* @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 changedParentTypes = transaction.changedParentTypes
while (true) {
@ -241,6 +242,18 @@ export const callTypeObservers = (type, transaction, event) => {
map.setIfUndefined(changedParentTypes, type, () => []).push(event)
if (type._item === null) {
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)
}

View File

@ -16,7 +16,8 @@ import {
YArrayRefID,
callTypeObservers,
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'
import { typeListSlice } from './AbstractType.js'
@ -196,6 +197,23 @@ export class YArray extends AbstractType {
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.
*

View File

@ -13,7 +13,8 @@ import {
YMapRefID,
callTypeObservers,
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'
import * as iterator from 'lib0/iterator'
@ -236,6 +237,16 @@ export class YMap extends AbstractType {
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.
*

View File

@ -25,8 +25,9 @@ import {
typeMapGet,
typeMapGetAll,
updateMarkerChanges,
quoteRange,
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'
import * as object from 'lib0/object'
@ -1004,107 +1005,7 @@ export class YText extends AbstractType {
* @public
*/
toDelta (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
return rangeDelta(this, null, null, snapshot, prevSnapshot, computeYChange)
}
/**
@ -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.
*
@ -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
* @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 () {
// @ts-ignore
return this.toDelta().map(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
}).join('')
return this.toDelta().map(delta => formatXmlString(delta)).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
* @return {YXmlText}

View File

@ -1,7 +1,7 @@
import {
GC,
splitItem,
Transaction, ID, Item, DSDecoderV2 // eslint-disable-line
Transaction, ID, Item, DSDecoderV2, YWeakLink // eslint-disable-line
} from '../internals.js'
import * as math from 'lib0/math'
@ -13,6 +13,13 @@ export class StructStore {
* @type {Map<number,Array<GC|Item>>}
*/
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 }}
*/

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 text from './y-text.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 undoredo from './undo-redo.tests.js'
import * as compatibility from './compatibility.tests.js'
@ -20,7 +21,7 @@ if (isBrowser) {
log.createVConsole(document.body)
}
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 => {
/* istanbul ignore next */
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'])
}