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 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) } } }