diff --git a/src/index.js b/src/index.js index b13b2077..f238e34a 100644 --- a/src/index.js +++ b/src/index.js @@ -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, diff --git a/src/internals.js b/src/internals.js index cb2fcac8..151621fc 100644 --- a/src/internals.js +++ b/src/internals.js @@ -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' diff --git a/src/structs/ContentType.js b/src/structs/ContentType.js index 630efeb3..aa592377 100644 --- a/src/structs/ContentType.js +++ b/src/structs/ContentType.js @@ -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} */ (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) { diff --git a/src/structs/Item.js b/src/structs/Item.js index 1b7ba939..0c07db4f 100644 --- a/src/structs/Item.js +++ b/src/structs/Item.js @@ -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} */ (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} */ (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} */ (this.parent)._length += this.length + if (this.parentSub === null && !this.deleted) { + if (this.countable) { + // adjust length of parent + /** @type {AbstractType} */ (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} */ (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} */ (this.parent)._item !== null && /** @type {AbstractType} */ (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 + } } } diff --git a/src/types/AbstractType.js b/src/types/AbstractType.js index 3dff240c..7e18905f 100644 --- a/src/types/AbstractType.js +++ b/src/types/AbstractType.js @@ -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} type * @param {Transaction} transaction * @param {EventType} event + * @param {Set>|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} */ (type._item.parent) } diff --git a/src/types/YArray.js b/src/types/YArray.js index c70f2cda..b8746d03 100644 --- a/src/types/YArray.js +++ b/src/types/YArray.js @@ -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} + */ + 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. * diff --git a/src/types/YMap.js b/src/types/YMap.js index 974e7316..2e2d9818 100644 --- a/src/types/YMap.js +++ b/src/types/YMap.js @@ -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|undefined} + */ + link (key) { + return mapWeakLink(this, key) + } + /** * Returns a boolean indicating whether the specified key exists or not. * diff --git a/src/types/YText.js b/src/types/YText.js index 8919b009..93c593be 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -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} - */ - 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} - */ - const attributes = {} - let addAttributes = false - currentAttributes.forEach((value, key) => { - addAttributes = true - attributes[key] = value - }) - /** - * @type {Object} - */ - 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} - */ - const op = { - insert: n.content.getContent()[0] - } - if (currentAttributes.size > 0) { - const attrs = /** @type {Object} */ ({}) - 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 + * `hello world` could result in `he"llo wo"rld` + * where `"llo wo"` represents quoted range. + * + * @param {YRange} range + * @return {YWeakLink} + * + * @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} 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} + */ + 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} + */ + const attributes = {} + let addAttributes = false + currentAttributes.forEach((value, key) => { + addAttributes = true + attributes[key] = value + }) + /** + * @type {Object} + */ + 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} + */ + const op = { + insert: n.content.getContent()[0] + } + if (currentAttributes.size > 0) { + const attrs = /** @type {Object} */ ({}) + 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} diff --git a/src/types/YWeakLink.js b/src/types/YWeakLink.js new file mode 100644 index 00000000..d62162b2 --- /dev/null +++ b/src/types/YWeakLink.js @@ -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 + * @extends YEvent + * Event that describes the changes on a YMap. + */ +export class YWeakLinkEvent extends YEvent { + /** + * @param {YWeakLink} 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> + * + * 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} + */ + unquote () { + let result = /** @type {Array} */ ([]) + 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} + */ + _copy () { + return new YWeakLink(this._quoteStart, this._quoteEnd, this._firstItem) + } + + /** + * @return {YWeakLink} + */ + clone () { + return new YWeakLink(this._quoteStart, this._quoteEnd, this._firstItem) + } + + /** + * Creates YWeakLinkEvent and calls observers. + * + * @param {Transaction} transaction + * @param {Set} 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} */ (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} + */ + toDelta (snapshot, prevSnapshot, computeYChange) { + if (this._firstItem !== null && this._quoteStart.item !== null && this._quoteEnd.item !== null) { + const parent = /** @type {AbstractType} */ (this._firstItem.parent) + return rangeDelta(parent, this._quoteStart.item, this._quoteEnd.item, snapshot, prevSnapshot, computeYChange) + } else { + return [] + } + } +} + +/** + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @return {YWeakLink} + */ +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} parent + * @param {Transaction} transaction + * @param {YRange} range + * @return {YWeakLink} + */ +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} 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} parent + * @param {string} key + * @return {YWeakLink|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} 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} 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) + } + } +} diff --git a/src/types/YXmlText.js b/src/types/YXmlText.js index ab02dbf3..dbf9a67c 100644 --- a/src/types/YXmlText.js +++ b/src/types/YXmlText.js @@ -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 += `` - } - 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 += `` + } + return str +} + /** * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @return {YXmlText} diff --git a/src/utils/StructStore.js b/src/utils/StructStore.js index 55a85178..106da468 100644 --- a/src/utils/StructStore.js +++ b/src/utils/StructStore.js @@ -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>} */ this.clients = new Map() + /** + * If this item was referenced by other weak links, here we keep the references + * to these weak refs. + * + * @type {Map>>} + */ + this.linkedBy = new Map() /** * @type {null | { missing: Map, update: Uint8Array }} */ diff --git a/src/utils/YRange.js b/src/utils/YRange.js new file mode 100644 index 00000000..4902a7e7 --- /dev/null +++ b/src/utils/YRange.js @@ -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} 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} 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] +} diff --git a/tests/index.js b/tests/index.js index ec22ed05..1dd7f4f8 100644 --- a/tests/index.js +++ b/tests/index.js @@ -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) { diff --git a/tests/y-weak-link.tests.js b/tests/y-weak-link.tests.js new file mode 100644 index 00000000..cc8ac7d8 --- /dev/null +++ b/tests/y-weak-link.tests.js @@ -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} */ (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>} */ (map0.link('a')) + map0.set('b', link0) + + testConnector.flushAllMessages() + const link1 = /** @type {Y.WeakLink>} */ (map1.get('b')) + let l1 = /** @type {Y.Map} */ (link1.deref()) + let l0 = /** @type {Y.Map} */ (link0.deref()) + t.compare(l1.get('a1'), l0.get('a1')) + + map1.get('a').set('a2', 'world') + + testConnector.flushAllMessages() + + l1 = /** @type {Y.Map} */ (link1.deref()) + l0 = /** @type {Y.Map} */ (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>} */ (map0.link('a')) + map0.set('b', link0) + + testConnector.flushAllMessages() + + const link1 = /** @type {Y.WeakLink} */ map1.get('b') + const l1 = /** @type {Y.Map} */ (link1.deref()) + const l0 = /** @type {Y.Map} */ (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>} */ (map0.link('a')) + map0.set('b', link0) + + testConnector.flushAllMessages() + const link1 = /** @type {Y.WeakLink>} */ (map1.get('b')) + const l1 = /** @type {Y.Map} */ (link1.deref()) + const l0 = /** @type {Y.Map} */ (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} */ (map0.link('a')) + map0.set('b', link0) + /** + * @type {any} + */ + let target0 + link0.observe((e) => { + target0 = e.target + }) + + testConnector.flushAllMessages() + + const link1 = /** @type {Y.WeakLink} */ (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} */ (map0.link('a')) + map0.set('b', link0) + /** + * @type {any} + */ + let target0 + link0.observe((e) => { + target0 = e.target + }) + + testConnector.flushAllMessages() + + const link1 = /** @type {Y.WeakLink} */ (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} */ (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} */ (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} */ (map2.link('key')) + map1.set('link-key', link1) + const link2 = /** @type {Y.WeakLink} */ (map1.link('link-key')) + map2.set('link-link', link2) + + /** + * @type {Array} + */ + 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} */ (map2.link('key')) + map1.set('link-key', link1) + const link2 = /** @type {Y.WeakLink} */ (map1.link('link-key')) + map2.set('link-link', link2) + const link3 = /** @type {Y.WeakLink} */ (map2.link('link-link')) + map3.set('link-link-link', link3) + + /** + * @type {Array} + */ + 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} + */ + 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} + */ + 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} + */ + 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} */ (array1.get(0)) + /** + * @type {Array} + */ + 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} + */ + 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} + */ + 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 }) // 'abcde' + const l1 = text.quote(Y.Range.bound(0, 1)) + t.compare(l1.toString(), 'ab') + const l2 = text.quote(Y.Range.only(2)) // 'c' + t.compare(l2.toString(), 'c') + const l3 = text.quote(Y.Range.bound(3, 4)) // 'de' + t.compare(l3.toString(), 'de') + + 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']) +}