From f3de5b0addf4025796e5f7aa9d5936cd283c4711 Mon Sep 17 00:00:00 2001 From: Bartosz Sypytkowski Date: Fri, 6 Oct 2023 11:30:31 +0200 Subject: [PATCH 1/4] draft of YWeakLink type --- src/index.js | 2 + src/internals.js | 1 + src/structs/ContentType.js | 24 +- src/structs/Item.js | 88 ++++- src/types/AbstractType.js | 17 +- src/types/YArray.js | 22 +- src/types/YMap.js | 13 +- src/types/YText.js | 270 ++++++++----- src/types/YWeakLink.js | 469 ++++++++++++++++++++++ src/types/YXmlText.js | 68 ++-- src/utils/StructStore.js | 9 +- tests/index.js | 3 +- tests/y-weak-link.tests.js | 778 +++++++++++++++++++++++++++++++++++++ 13 files changed, 1620 insertions(+), 144 deletions(-) create mode 100644 src/types/YWeakLink.js create mode 100644 tests/y-weak-link.tests.js diff --git a/src/index.js b/src/index.js index 781e8eb4..d46e48e0 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,8 @@ export { YXmlHook as XmlHook, YXmlElement as XmlElement, YXmlFragment as XmlFragment, + YWeakLink as WeakLink, + YWeakLinkEvent, YXmlEvent, YMapEvent, YArrayEvent, diff --git a/src/internals.js b/src/internals.js index bc386f0a..c1229bfc 100644 --- a/src/internals.js +++ b/src/internals.js @@ -27,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 e9c11de1..c6958941 100644 --- a/src/structs/ContentType.js +++ b/src/structs/ContentType.js @@ -6,8 +6,11 @@ import { readYXmlElement, readYXmlFragment, readYXmlHook, - readYXmlText, - UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line + readYXmlText, + 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' @@ -33,6 +36,7 @@ export const YXmlElementRefID = 3 export const YXmlFragmentRefID = 4 export const YXmlHookRefID = 5 export const YXmlTextRefID = 6 +export const YWeakLinkRefID = 7 /** * @private @@ -104,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 c14778b3..372f8254 100644 --- a/src/structs/Item.js +++ b/src/structs/Item.js @@ -23,7 +23,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' @@ -105,6 +107,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 @@ -304,11 +314,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 { if (this.parent && this.parent.constructor === ID && this.id.client !== this.parent.client && this.parent.clock >= getState(store, this.parent.client)) { return this.parent.client } + + if (this.content.constructor === ContentType && /** @type {ContentType} */ (this.content).type.constructor === YWeakLink) { + // make sure that linked content is integrated first + const content = /** @type {ContentType} */ (this.content) + const link = /** @type {YWeakLink} */ (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 @@ -508,18 +549,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 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) @@ -577,6 +643,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) ) { @@ -622,6 +689,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 3163b8da..2de1708b 100644 --- a/src/types/AbstractType.js +++ b/src/types/AbstractType.js @@ -11,7 +11,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' @@ -233,8 +233,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) { @@ -242,6 +243,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 (let 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 a895274e..0ea2cb7e 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 + arrayWeakLink, + ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item, YWeakLink, // eslint-disable-line } from '../internals.js' import { typeListSlice } from './AbstractType.js' @@ -200,6 +201,25 @@ export class YArray extends AbstractType { get (index) { return typeListGet(this, index) } + + /** + * Returns the weak link that allows to refer and observe live changes of contents of an YArray. + * It points at a consecutive range of elements, starting at give `index` and spanning over provided + * length of elements. + * + * @param {number} index The index of the element to return from the YArray + * @param {number} length The number of elements to include in returned weak link reference. + * @return {YWeakLink} + */ + quote (index, length = 1) { + if (this.doc !== null) { + return transact(this.doc, transaction => { + return arrayWeakLink(transaction, this, index, length) + }) + } else { + 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 e2dd7a49..c97c07b3 100644 --- a/src/types/YMap.js +++ b/src/types/YMap.js @@ -14,7 +14,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' @@ -232,6 +233,16 @@ export class YMap extends AbstractType { get (key) { return /** @type {any} */ (typeMapGet(this, key)) } + + /** + * Returns a weak reference link to another element stored in the same document. + * + * @param {string} key + * @return {YWeakLink|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 399a6ff3..caf3d8b1 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -27,7 +27,8 @@ import { typeMapGetAll, updateMarkerChanges, 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, // eslint-disable-line + quoteText } from '../internals.js' import * as object from 'lib0/object' @@ -1000,107 +1001,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) } /** @@ -1155,6 +1056,31 @@ 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 {number} index The index where quoted range should start + * @param {number} length Number of quoted elements + * @return {YWeakLink} + * + * @public + */ + quote (index, length) { + const y = this.doc + if (y !== null) { + return transact(y, transaction => { + const pos = findPosition(transaction, this, index) + return quoteText(transaction, this, pos, length) + }) + } + throw new Error('Quoted text was not integrated into Doc') + } + /** * Deletes text starting from an index. * @@ -1280,6 +1206,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..292ef418 --- /dev/null +++ b/src/types/YWeakLink.js @@ -0,0 +1,469 @@ +import { decoding, encoding, error } from 'lib0' +import * as map from 'lib0/map' +import * as set from 'lib0/set' +import { + YEvent, AbstractType, + transact, + getItemCleanEnd, + createID, + getItemCleanStart, + callTypeObservers, + YWeakLinkRefID, + writeID, + readID, + RelativePosition, + ContentString, + rangeDelta, + formatXmlString, + YText, + YXmlText, + Transaction, Item, Doc, ID, Snapshot, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ItemTextListPosition // 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 + 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 (!this._firstItem.deleted) { + return this._firstItem.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 item = this._firstItem + const end = /** @type {ID} */ (this._quoteEnd.item) + // TODO: moved elements + while (item !== null) { + if (!item.deleted) { + result = result.concat(item.content.getContent()) + } + const lastId = item.lastId + if (lastId.client === end.client && lastId.clock === end.clock) { + break + } + item = item.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 = this._firstItem !== null ? this._firstItem : getItemCleanStart(transaction, /** @type {ID} */ (this._quoteStart.item)) + getItemCleanEnd(transaction, y.store, /** @type {ID} */(this._quoteEnd.item)) + 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 + const end = /** @type {ID} */ (this._quoteEnd.item) + for (;item !== null; item = item.right) { + createLink(transaction, item, this) + const lastId = item.lastId + if (lastId.client === end.client && lastId.clock === end.clock) { + 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 + 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 () { + if (this._firstItem !== null) { + switch (/** @type {AbstractType} */ (this._firstItem.parent).constructor) { + case YText: { + let str = '' + /** + * @type {Item|null} + */ + let n = this._firstItem + const end = /** @type {ID} */ (this._quoteEnd.item) + while (n !== null) { + if (!n.deleted && n.countable && n.content.constructor === ContentString) { + str += /** @type {ContentString} */ (n.content).str + } + const lastId = n.lastId + if (lastId.client === end.client && lastId.clock === end.clock) { + 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) +} + +const invalidQuotedRange = error.create('Invalid quoted range length.') + +/** + * Returns a {WeakLink} to an YArray element at given index. + * + * @param {Transaction} transaction + * @param {AbstractType} parent + * @param {number} index + * @return {YWeakLink} + */ +export const arrayWeakLink = (transaction, parent, index, length = 1) => { + if (length <= 0) { + throw invalidQuotedRange + } + let startItem = parent._start + for (;startItem !== null; startItem = startItem.right) { + if (!startItem.deleted && startItem.countable) { + if (index < startItem.length) { + if (index > 0) { + startItem = getItemCleanStart(transaction, createID(startItem.id.client, startItem.id.clock + index)) + } + break + } + index -= startItem.length + } + } + let endItem = startItem + let remaining = length + for (;endItem !== null; endItem = endItem.right) { + if (!endItem.deleted && endItem.countable) { + if (remaining > endItem.length) { + remaining -= endItem.length + } else { + endItem = getItemCleanEnd(transaction, transaction.doc.store, createID(endItem.id.client, endItem.id.clock + remaining - 1)) + break + } + } + } + if (startItem !== null && endItem !== null) { + const start = new RelativePosition(null, null, startItem.id, 0) + const end = new RelativePosition(null, null, endItem.lastId, -1) + const link = new YWeakLink(start, end, startItem) + if (parent.doc !== null) { + transact(parent.doc, (transaction) => { + const end = /** @type {ID} */ (link._quoteEnd.item) + for (let item = link._firstItem; item !== null; item = item = item.right) { + createLink(transaction, item, link) + const lastId = item.lastId + if (lastId.client === end.client && lastId.clock === end.clock) { + break + } + } + }) + } + return link + } + throw invalidQuotedRange +} + +/** + * Returns a {WeakLink} to an YMap element at given key. + * + * @param {Transaction} transaction + * @param {AbstractType} parent + * @param {ItemTextListPosition} pos + * @param {number} length + * @return {YWeakLink} + */ +export const quoteText = (transaction, parent, pos, length) => { + if (pos.right !== null) { + const startItem = pos.right + const endIndex = pos.index + length + while (pos.index < endIndex) { + pos.forward() + } + if (pos.left !== null) { + let endItem = pos.left + if (pos.index > endIndex) { + const overflow = pos.index - endIndex + endItem = getItemCleanEnd(transaction, transaction.doc.store, createID(endItem.id.client, endItem.id.clock + endItem.length - overflow - 1)) + } + const start = new RelativePosition(null, null, startItem.id, 0) + const end = new RelativePosition(null, null, endItem.lastId, -1) + const link = new YWeakLink(start, end, startItem) + if (parent.doc !== null) { + transact(parent.doc, (transaction) => { + const end = /** @type {ID} */ (link._quoteEnd.item) + for (let item = link._firstItem; item !== null; item = item = item.right) { + createLink(transaction, item, link) + const lastId = item.lastId + if (lastId.client === end.client && lastId.clock === end.clock) { + break + } + } + }) + } + return link + } + } + + throw invalidQuotedRange +} + +/** + * 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)) { + common.add(link) + } + } + if (common.size !== 0) { + allLinks.set(item, common) + } + } +} \ No newline at end of file diff --git a/src/types/YXmlText.js b/src/types/YXmlText.js index 470ce70f..136271c1 100644 --- a/src/types/YXmlText.js +++ b/src/types/YXmlText.js @@ -64,36 +64,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('') } /** @@ -111,6 +82,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 7a2e256c..75742b21 100644 --- a/src/utils/StructStore.js +++ b/src/utils/StructStore.js @@ -2,7 +2,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' @@ -14,6 +14,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/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..da8450cd --- /dev/null +++ b/tests/y-weak-link.tests.js @@ -0,0 +1,778 @@ +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(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(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(0, 3) + 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(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(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(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(0) + const l1 = root.quote(1) + const l2 = root.quote(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(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(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(0, 2) + t.compare(l1.toString(), 'ab') + const l2 = text.quote(2, 1) // 'c' + t.compare(l2.toString(), 'c') + const l3 = text.quote(3, 2) // '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 } + ]) +} \ No newline at end of file From f961aa960d1409fe067c18d348a00d1516962260 Mon Sep 17 00:00:00 2001 From: Bartosz Sypytkowski Date: Sat, 7 Oct 2023 04:36:49 +0200 Subject: [PATCH 2/4] fixed decoding issue --- src/structs/ContentType.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/structs/ContentType.js b/src/structs/ContentType.js index c6958941..9c3d8b2c 100644 --- a/src/structs/ContentType.js +++ b/src/structs/ContentType.js @@ -26,7 +26,8 @@ export const typeRefs = [ readYXmlElement, readYXmlFragment, readYXmlHook, - readYXmlText + readYXmlText, + readYWeakLink ] export const YArrayRefID = 0 From a01113812c9a5e885825dc5221010bae892cb281 Mon Sep 17 00:00:00 2001 From: Bartosz Sypytkowski Date: Thu, 26 Oct 2023 09:56:31 +0900 Subject: [PATCH 3/4] introduced Y.Range for inclusive and exclusive range definitions --- src/index.js | 1 + src/internals.js | 1 + src/structs/ContentType.js | 2 +- src/structs/Item.js | 6 +- src/types/AbstractType.js | 2 +- src/types/YArray.js | 16 ++- src/types/YMap.js | 2 +- src/types/YText.js | 20 ++-- src/types/YWeakLink.js | 207 ++++++++++++++++------------------- src/utils/YRange.js | 214 +++++++++++++++++++++++++++++++++++++ tests/y-weak-link.tests.js | 127 +++++++++++++++++++--- 11 files changed, 441 insertions(+), 157 deletions(-) create mode 100644 src/utils/YRange.js diff --git a/src/index.js b/src/index.js index d46e48e0..973f1cb9 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,7 @@ export { YArrayEvent, YTextEvent, YEvent, + YRange as Range, Item, AbstractStruct, GC, diff --git a/src/internals.js b/src/internals.js index c1229bfc..1103000f 100644 --- a/src/internals.js +++ b/src/internals.js @@ -17,6 +17,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' diff --git a/src/structs/ContentType.js b/src/structs/ContentType.js index 9c3d8b2c..afca9b06 100644 --- a/src/structs/ContentType.js +++ b/src/structs/ContentType.js @@ -6,7 +6,7 @@ import { readYXmlElement, readYXmlFragment, readYXmlHook, - readYXmlText, + readYXmlText, readYWeakLink, unlinkFrom, YWeakLink, diff --git a/src/structs/Item.js b/src/structs/Item.js index 372f8254..5878a2a5 100644 --- a/src/structs/Item.js +++ b/src/structs/Item.js @@ -403,7 +403,7 @@ export class Item extends AbstractStruct { if (this.parent && this.parent.constructor === ID && this.id.client !== this.parent.client && this.parent.clock >= getState(store, this.parent.client)) { return this.parent.client } - + if (this.content.constructor === ContentType && /** @type {ContentType} */ (this.content).type.constructor === YWeakLink) { // make sure that linked content is integrated first const content = /** @type {ContentType} */ (this.content) @@ -568,8 +568,8 @@ export class Item extends AbstractStruct { // adjust length of parent /** @type {AbstractType} */ (this.parent)._length += this.length } - if (this.left && this.left.linked && this.right && this.right.linked) { - // this item exists within a quoted range + if ((this.left && this.left.linked) || (this.right && this.right.linked)) { + // this item may exists within a quoted range joinLinkedRange(transaction, this) } } diff --git a/src/types/AbstractType.js b/src/types/AbstractType.js index 2de1708b..744c4830 100644 --- a/src/types/AbstractType.js +++ b/src/types/AbstractType.js @@ -246,7 +246,7 @@ export const callTypeObservers = (type, transaction, event, visitedLinks = null) } else if (type._item.linked) { const linkedBy = transaction.doc.store.linkedBy.get(type._item) if (linkedBy !== undefined) { - for (let link of linkedBy) { + for (const link of linkedBy) { if (visitedLinks === null || !visitedLinks.has(link)) { visitedLinks = visitedLinks !== null ? visitedLinks : new Set() visitedLinks.add(link) diff --git a/src/types/YArray.js b/src/types/YArray.js index 0ea2cb7e..e45b4dce 100644 --- a/src/types/YArray.js +++ b/src/types/YArray.js @@ -16,8 +16,8 @@ import { YArrayRefID, callTypeObservers, transact, - arrayWeakLink, - ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item, YWeakLink, // 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' @@ -201,24 +201,22 @@ export class YArray extends AbstractType { get (index) { return typeListGet(this, index) } - + /** * Returns the weak link that allows to refer and observe live changes of contents of an YArray. * It points at a consecutive range of elements, starting at give `index` and spanning over provided * length of elements. * - * @param {number} index The index of the element to return from the YArray - * @param {number} length The number of elements to include in returned weak link reference. + * @param {YRange} range quoted range * @return {YWeakLink} */ - quote (index, length = 1) { + quote (range) { if (this.doc !== null) { return transact(this.doc, transaction => { - return arrayWeakLink(transaction, this, index, length) + return quoteRange(transaction, this, range) }) - } else { - throw new Error('cannot quote an YArray that has not been integrated into YDoc') } + throw new Error('cannot quote an YArray that has not been integrated into YDoc') } /** diff --git a/src/types/YMap.js b/src/types/YMap.js index c97c07b3..1fc75ad9 100644 --- a/src/types/YMap.js +++ b/src/types/YMap.js @@ -233,7 +233,7 @@ export class YMap extends AbstractType { get (key) { return /** @type {any} */ (typeMapGet(this, key)) } - + /** * Returns a weak reference link to another element stored in the same document. * diff --git a/src/types/YText.js b/src/types/YText.js index caf3d8b1..2c01a091 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -26,9 +26,9 @@ import { typeMapGet, typeMapGetAll, updateMarkerChanges, + quoteRange, ContentType, - ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction, YWeakLink, // eslint-disable-line - quoteText + ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction, YWeakLink, YRange, // eslint-disable-line } from '../internals.js' import * as object from 'lib0/object' @@ -1064,21 +1064,19 @@ export class YText extends AbstractType { * `hello world` could result in `he"llo wo"rld` * where `"llo wo"` represents quoted range. * - * @param {number} index The index where quoted range should start - * @param {number} length Number of quoted elements + * @param {YRange} range * @return {YWeakLink} * * @public */ - quote (index, length) { - const y = this.doc - if (y !== null) { - return transact(y, transaction => { - const pos = findPosition(transaction, this, index) - return quoteText(transaction, this, pos, length) + quote (range) { + if (this.doc !== null) { + return transact(this.doc, transaction => { + return quoteRange(transaction, this, range) }) } - throw new Error('Quoted text was not integrated into Doc') + + throw new Error('cannot quote an YText that has not been integrated into YDoc') } /** diff --git a/src/types/YWeakLink.js b/src/types/YWeakLink.js index 292ef418..cc406153 100644 --- a/src/types/YWeakLink.js +++ b/src/types/YWeakLink.js @@ -1,11 +1,10 @@ -import { decoding, encoding, error } from 'lib0' +import { decoding, encoding } from 'lib0' import * as map from 'lib0/map' import * as set from 'lib0/set' import { YEvent, AbstractType, transact, getItemCleanEnd, - createID, getItemCleanStart, callTypeObservers, YWeakLinkRefID, @@ -17,7 +16,7 @@ import { formatXmlString, YText, YXmlText, - Transaction, Item, Doc, ID, Snapshot, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ItemTextListPosition // eslint-disable-line + Transaction, Item, Doc, ID, Snapshot, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, YRange, rangeToRelative, // eslint-disable-line } from '../internals.js' /** @@ -54,12 +53,13 @@ export class YWeakLink extends AbstractType { 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 () { @@ -68,7 +68,7 @@ export class YWeakLink extends AbstractType { /** * Position descriptor of the end of a quoted range. - * + * * @returns {RelativePosition} */ get quoteEnd () { @@ -99,8 +99,8 @@ export class YWeakLink extends AbstractType { // we don't support quotations over maps this._firstItem = item } - if (!this._firstItem.deleted) { - return this._firstItem.content.getContent()[0] + if (!item.deleted) { + return item.content.getContent()[0] } } @@ -114,18 +114,27 @@ export class YWeakLink extends AbstractType { */ unquote () { let result = /** @type {Array} */ ([]) - let item = this._firstItem + 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 (item !== null) { - if (!item.deleted) { - result = result.concat(item.content.getContent()) - } - const lastId = item.lastId - if (lastId.client === end.client && lastId.clock === end.clock) { + while (n !== null) { + if (endAssoc < 0 && n.id.client === end.client && n.id.clock === end.clock) { + // right side is open (last item excluded) break } - item = item.right + 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 } @@ -147,8 +156,7 @@ export class YWeakLink extends AbstractType { // 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 = this._firstItem !== null ? this._firstItem : getItemCleanStart(transaction, /** @type {ID} */ (this._quoteStart.item)) - getItemCleanEnd(transaction, y.store, /** @type {ID} */(this._quoteEnd.item)) + let [firstItem, lastItem] = sliceBlocksByRange(transaction, this._quoteStart, this.quoteEnd) if (firstItem.parentSub !== null) { // for maps, advance to most recent item while (firstItem.right !== null) { @@ -159,11 +167,9 @@ export class YWeakLink extends AbstractType { /** @type {Item|null} */ let item = firstItem - const end = /** @type {ID} */ (this._quoteEnd.item) for (;item !== null; item = item.right) { createLink(transaction, item, this) - const lastId = item.lastId - if (lastId.client === end.client && lastId.clock === end.clock) { + if (item === lastItem) { break } } @@ -216,22 +222,31 @@ export class YWeakLink extends AbstractType { * @public */ toString () { - if (this._firstItem !== null) { - switch (/** @type {AbstractType} */ (this._firstItem.parent).constructor) { + 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 = '' - /** - * @type {Item|null} - */ - let n = this._firstItem 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 } - const lastId = n.lastId - if (lastId.client === end.client && lastId.clock === end.clock) { - break + 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 } @@ -278,107 +293,54 @@ export const readYWeakLink = decoder => { return new YWeakLink(start, end, null) } -const invalidQuotedRange = error.create('Invalid quoted range length.') - /** * Returns a {WeakLink} to an YArray element at given index. * - * @param {Transaction} transaction * @param {AbstractType} parent - * @param {number} index + * @param {Transaction} transaction + * @param {YRange} range * @return {YWeakLink} */ -export const arrayWeakLink = (transaction, parent, index, length = 1) => { - if (length <= 0) { - throw invalidQuotedRange - } - let startItem = parent._start - for (;startItem !== null; startItem = startItem.right) { - if (!startItem.deleted && startItem.countable) { - if (index < startItem.length) { - if (index > 0) { - startItem = getItemCleanStart(transaction, createID(startItem.id.client, startItem.id.clock + index)) +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 } - break } - index -= startItem.length - } + }) } - let endItem = startItem - let remaining = length - for (;endItem !== null; endItem = endItem.right) { - if (!endItem.deleted && endItem.countable) { - if (remaining > endItem.length) { - remaining -= endItem.length - } else { - endItem = getItemCleanEnd(transaction, transaction.doc.store, createID(endItem.id.client, endItem.id.clock + remaining - 1)) - break - } - } - } - if (startItem !== null && endItem !== null) { - const start = new RelativePosition(null, null, startItem.id, 0) - const end = new RelativePosition(null, null, endItem.lastId, -1) - const link = new YWeakLink(start, end, startItem) - if (parent.doc !== null) { - transact(parent.doc, (transaction) => { - const end = /** @type {ID} */ (link._quoteEnd.item) - for (let item = link._firstItem; item !== null; item = item = item.right) { - createLink(transaction, item, link) - const lastId = item.lastId - if (lastId.client === end.client && lastId.clock === end.clock) { - break - } - } - }) - } - return link - } - throw invalidQuotedRange + return link } /** - * Returns a {WeakLink} to an YMap element at given key. + * Checks relative position markers and slices the corresponding struct store items + * across their positions. * * @param {Transaction} transaction - * @param {AbstractType} parent - * @param {ItemTextListPosition} pos - * @param {number} length - * @return {YWeakLink} + * @param {RelativePosition} start + * @param {RelativePosition} end + * @returns {Array} first and last item that belongs to a sliced range */ -export const quoteText = (transaction, parent, pos, length) => { - if (pos.right !== null) { - const startItem = pos.right - const endIndex = pos.index + length - while (pos.index < endIndex) { - pos.forward() - } - if (pos.left !== null) { - let endItem = pos.left - if (pos.index > endIndex) { - const overflow = pos.index - endIndex - endItem = getItemCleanEnd(transaction, transaction.doc.store, createID(endItem.id.client, endItem.id.clock + endItem.length - overflow - 1)) - } - const start = new RelativePosition(null, null, startItem.id, 0) - const end = new RelativePosition(null, null, endItem.lastId, -1) - const link = new YWeakLink(start, end, startItem) - if (parent.doc !== null) { - transact(parent.doc, (transaction) => { - const end = /** @type {ID} */ (link._quoteEnd.item) - for (let item = link._firstItem; item !== null; item = item = item.right) { - createLink(transaction, item, link) - const lastId = item.lastId - if (lastId.client === end.client && lastId.clock === end.clock) { - break - } - } - }) - } - return link - } +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') } - - throw invalidQuotedRange + 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] } /** @@ -459,6 +421,21 @@ export const joinLinkedRange = (transaction, item) => { 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) } } @@ -466,4 +443,4 @@ export const joinLinkedRange = (transaction, item) => { allLinks.set(item, common) } } -} \ No newline at end of file +} 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/y-weak-link.tests.js b/tests/y-weak-link.tests.js index da8450cd..cc8ac7d8 100644 --- a/tests/y-weak-link.tests.js +++ b/tests/y-weak-link.tests.js @@ -27,7 +27,7 @@ export const testBasicMap = tc => { export const testBasicArray = tc => { const { testConnector, array0, array1 } = init(tc, { users: 2 }) array0.insert(0, [1, 2, 3]) - array0.insert(3, [array0.quote(1)]) + array0.insert(3, [array0.quote(Y.Range.only(1))]) t.compare(array0.get(0), 1) t.compare(array0.get(1), 2) @@ -49,7 +49,7 @@ 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(1, 3)]) + array0.insert(0, [array0.quote(Y.Range.bound(1, 3))]) const link0 = array0.get(0) t.compare(link0.unquote(), [2, nested, 3]) @@ -89,7 +89,7 @@ export const testArrayQuoteMultipleElements = tc => { export const testSelfQuotation = tc => { const { testConnector, array0, array1 } = init(tc, { users: 2 }) array0.insert(0, [1, 2, 3, 4]) - const link0 = array0.quote(0, 3) + 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]) @@ -259,7 +259,7 @@ export const testObserveMapDelete = 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(1, 2)) + const link0 = /** @type {Y.WeakLink} */ (array0.quote(Y.Range.bound(1, 2))) array0.insert(0, [link0]) /** * @type {any} @@ -405,7 +405,7 @@ export const testDeepObserveMap = tc => { const nested = new Y.Map() array.insert(0, [nested]) - const link = array.quote(0) + const link = array.quote(Y.Range.only(0)) map.set('link', link) // update entry in linked map @@ -501,7 +501,7 @@ export const testDeepObserveNewElementWithinQuotedRange = tc => { const m1 = new Y.Map() const m3 = new Y.Map() array0.insert(0, [1, m1, m3, 2]) - const link0 = array0.quote(1, 2) + const link0 = array0.quote(Y.Range.bound(1, 2)) array0.insert(0, [link0]) testConnector.flushAllMessages() @@ -635,9 +635,9 @@ export const testDeepObserveRecursive = tc => { root.insert(1, [m1]) root.insert(2, [m2]) - const l0 = root.quote(0) - const l1 = root.quote(1) - const l2 = root.quote(2) + 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) @@ -649,7 +649,7 @@ export const testDeepObserveRecursive = tc => { */ let events = [] m0.observeDeep((es) => { - events = es.map((e) => { + events = es.map((e) => { return { target: e.target, keys: e.keys } }) }) @@ -709,7 +709,7 @@ export const testTextBasic = tc => { const { testConnector, text0, text1 } = init(tc, { users: 2 }) text0.insert(0, 'abcd') // 'abcd' - const link0 = text0.quote(1, 2) // quote: [bc] + 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') @@ -733,7 +733,7 @@ export const testXmlTextBasic = tc => { xml0.insert(0, [text0]) text0.insert(0, 'abcd') // 'abcd' - const link0 = text0.quote(1, 2) // quote: [bc] + 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') @@ -747,6 +747,7 @@ export const testXmlTextBasic = tc => { const { insert } = delta[1] // YWeakLink t.compare(insert.toString(), 'be') } + /** * @param {t.TestCase} tc */ @@ -758,11 +759,11 @@ export const testQuoteFormattedText = tc => { text.insert(0, 'abcde') text.format(0, 1, { b: true }) text.format(1, 3, { i: true }) // 'abcde' - const l1 = text.quote(0, 2) + const l1 = text.quote(Y.Range.bound(0, 1)) t.compare(l1.toString(), 'ab') - const l2 = text.quote(2, 1) // 'c' + const l2 = text.quote(Y.Range.only(2)) // 'c' t.compare(l2.toString(), 'c') - const l3 = text.quote(3, 2) // 'de' + const l3 = text.quote(Y.Range.bound(3, 4)) // 'de' t.compare(l3.toString(), 'de') text2.insertEmbed(0, l1) @@ -775,4 +776,98 @@ export const testQuoteFormattedText = tc => { { insert: l2 }, { insert: l3 } ]) -} \ No newline at end of file +} + +/** + * @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']) +} From 15279e430c0c0125fd0642ef76d183db1b5d3422 Mon Sep 17 00:00:00 2001 From: Bartosz Sypytkowski Date: Thu, 26 Oct 2023 10:34:30 +0900 Subject: [PATCH 4/4] weak links decoding proposal --- src/types/YWeakLink.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/types/YWeakLink.js b/src/types/YWeakLink.js index cc406153..d62162b2 100644 --- a/src/types/YWeakLink.js +++ b/src/types/YWeakLink.js @@ -208,6 +208,23 @@ export class YWeakLink extends AbstractType { _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))