From 69131b76c853d7ba4ae455fd39058b628ede1af3 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Sun, 28 Dec 2025 14:52:57 +0100 Subject: [PATCH] started refactor expose a single Yjs type only using lib0@v1 delta --- src/index.js | 2 +- src/internals.js | 11 +- src/structs/Item.js | 8 +- src/utils/AbstractConnector.js | 25 - src/utils/Doc.js | 116 +- src/utils/ID.js | 4 +- src/utils/RelativePosition.js | 14 +- src/utils/Transaction.js | 217 +++- src/utils/UndoManager.js | 4 +- src/utils/YEvent.js | 20 +- src/utils/delta-helpers.js | 6 +- src/utils/isParentOf.js | 6 +- src/utils/logging.js | 4 +- src/utils/types.js | 27 +- src/utils/updates.js | 10 +- src/ytype.js | 2065 ++++++++++++++++++++++++++++++++ 16 files changed, 2319 insertions(+), 220 deletions(-) delete mode 100644 src/utils/AbstractConnector.js create mode 100644 src/ytype.js diff --git a/src/index.js b/src/index.js index 7fe08088..2b6c0a01 100644 --- a/src/index.js +++ b/src/index.js @@ -24,7 +24,7 @@ export { ContentAny, ContentString, ContentType, - AbstractType, + YType as AbstractType, getTypeChildren, createRelativePositionFromTypeIndex, createRelativePositionFromJSON, diff --git a/src/internals.js b/src/internals.js index 96485c41..c7b1f5c1 100644 --- a/src/internals.js +++ b/src/internals.js @@ -19,16 +19,7 @@ export * from './utils/StructSet.js' export * from './utils/IdMap.js' export * from './utils/AttributionManager.js' export * from './utils/delta-helpers.js' - -export * from './types/AbstractType.js' -export * from './types/YArray.js' -export * from './types/YMap.js' -export * from './types/YText.js' -export * from './types/YXmlFragment.js' -export * from './types/YXmlElement.js' -export * from './types/YXmlHook.js' -export * from './types/YXmlText.js' - +export * from './ytype.js' export * from './structs/AbstractStruct.js' export * from './structs/GC.js' export * from './structs/ContentBinary.js' diff --git a/src/structs/Item.js b/src/structs/Item.js index 137d328d..b09896d0 100644 --- a/src/structs/Item.js +++ b/src/structs/Item.js @@ -22,7 +22,7 @@ import { readContentType, addChangedTypeToTransaction, addStructToIdSet, - IdSet, StackItem, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction, // eslint-disable-line + IdSet, StackItem, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, YType, Transaction, // eslint-disable-line } from '../internals.js' import * as error from 'lib0/error' @@ -282,7 +282,7 @@ export class Item extends AbstractStruct { * @param {ID | null} origin * @param {Item | null} right * @param {ID | null} rightOrigin - * @param {AbstractType|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it. + * @param {YType|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it. * @param {string | null} parentSub * @param {AbstractContent} content */ @@ -309,7 +309,7 @@ export class Item extends AbstractStruct { */ this.rightOrigin = rightOrigin /** - * @type {AbstractType|ID|null} + * @type {YType|ID|null} */ this.parent = parent /** @@ -696,7 +696,7 @@ export class Item extends AbstractStruct { encoder.writeRightID(rightOrigin) } if (origin === null && rightOrigin === null) { - const parent = /** @type {AbstractType} */ (this.parent) + const parent = /** @type {YType} */ (this.parent) if (parent._item !== undefined) { const parentItem = parent._item if (parentItem === null) { diff --git a/src/utils/AbstractConnector.js b/src/utils/AbstractConnector.js deleted file mode 100644 index f5c0566a..00000000 --- a/src/utils/AbstractConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { ObservableV2 } from 'lib0/observable' - -import { - Doc // eslint-disable-line -} from '../internals.js' - -/** - * This is an abstract interface that all Connectors should implement to keep them interchangeable. - * - * @note This interface is experimental and it is not advised to actually inherit this class. - * It just serves as typing information. - * - * @extends {ObservableV2} - */ -export class AbstractConnector extends ObservableV2 { - /** - * @param {Doc} ydoc - * @param {any} awareness - */ - constructor (ydoc, awareness) { - super() - this.doc = ydoc - this.awareness = awareness - } -} diff --git a/src/utils/Doc.js b/src/utils/Doc.js index 9f6bc2dc..a15ccd9b 100644 --- a/src/utils/Doc.js +++ b/src/utils/Doc.js @@ -4,12 +4,7 @@ import { StructStore, - AbstractType, - YArray, - YText, - YMap, - YXmlElement, - YXmlFragment, + YType, transact, applyUpdate, ContentDoc, Item, Transaction, // eslint-disable-line @@ -24,13 +19,6 @@ import * as promise from 'lib0/promise' export const generateNewClientId = random.uint32 -/** - * @typedef {import('../utils/types.js').YTypeConstructors} YTypeConstructors - */ -/** - * @typedef {import('../utils/types.js').YType} YType - */ - /** * @typedef {Object} DocOpts * @property {boolean} [DocOpts.gc=true] Disable garbage collection (default: gc=true) @@ -212,105 +200,17 @@ export class Doc extends ObservableV2 { * Define all types right after the Y.Doc instance is created and store them in a separate object. * Also use the typed methods `getText(name)`, `getArray(name)`, .. * - * @template {YTypeConstructors} [TypeC=typeof AbstractType] - * @example - * const ydoc = new Y.Doc(..) - * const appState = { - * document: ydoc.getText('document') - * comments: ydoc.getArray('comments') - * } + * @param {string} key + * @param {string?} name Type-name * - * @param {string} name - * @param {TypeC} TypeConstructor The constructor of the type definition. E.g. Y.Text, Y.Array, Y.Map, ... - * @return {AbstractType} The created type. Constructed with TypeConstructor - * - * @public + * @return {YType} */ - get (name, TypeConstructor = /** @type {any} */ (AbstractType)) { - const type = map.setIfUndefined(this.share, name, () => { - // @ts-ignore - const t = new TypeConstructor() + get (key, name = null) { + return map.setIfUndefined(this.share, key, () => { + const t = new YType(name) t._integrate(this, null) return t }) - const Constr = type.constructor - // @ts-ignore - if (TypeConstructor !== AbstractType && Constr !== TypeConstructor) { - if (Constr === AbstractType) { - // @ts-ignore - const t = new TypeConstructor() - t._map = type._map - type._map.forEach(/** @param {Item?} n */ n => { - for (; n !== null; n = n.left) { - // @ts-ignore - n.parent = t - } - }) - t._start = type._start - for (let n = t._start; n !== null; n = n.right) { - n.parent = t - } - t._length = type._length - this.share.set(name, t) - t._integrate(this, null) - return /** @type {InstanceType} */ (t) - } else { - throw new Error(`Type with the name ${name} has already been defined with a different constructor`) - } - } - return /** @type {InstanceType} */ (type) - } - - /** - * @template T - * @param {string} [name] - * @return {YArray} - * - * @public - */ - getArray (name = '') { - return /** @type {YArray} */ (this.get(name, YArray)) - } - - /** - * @param {string} [name] - * @return {YText} - * - * @public - */ - getText (name = '') { - return /** @type {YText} */ (this.get(name, YText)) - } - - /** - * @template T - * @param {string} [name] - * @return {YMap} - * - * @public - */ - getMap (name = '') { - return /** @type {YMap} */ (this.get(name, YMap)) - } - - /** - * @param {string} [name] - * @return {YXmlElement} - * - * @public - */ - getXmlElement (name = '') { - return /** @type {YXmlElement<{[key:string]:string}>} */ (this.get(name, YXmlElement)) - } - - /** - * @param {string} [name] - * @return {YXmlFragment} - * - * @public - */ - getXmlFragment (name = '') { - return /** @type {YXmlFragment} */ (this.get(name, YXmlFragment)) } /** @@ -326,11 +226,9 @@ export class Doc extends ObservableV2 { * @type {Object} */ const doc = {} - this.share.forEach((value, key) => { doc[key] = value.toJSON() }) - return doc } diff --git a/src/utils/ID.js b/src/utils/ID.js index b0cabd8f..90352e27 100644 --- a/src/utils/ID.js +++ b/src/utils/ID.js @@ -1,4 +1,4 @@ -import { AbstractType } from '../internals.js' // eslint-disable-line +import { YType } from '../internals.js' // eslint-disable-line import * as decoding from 'lib0/decoding' import * as encoding from 'lib0/encoding' @@ -72,7 +72,7 @@ export const readID = decoder => * `type` does not store any information about the `keyname`. * This function finds the correct `keyname` for `type` and throws otherwise. * - * @param {AbstractType} type + * @param {YType} type * @return {string} * * @private diff --git a/src/utils/RelativePosition.js b/src/utils/RelativePosition.js index c89aa266..9820c231 100644 --- a/src/utils/RelativePosition.js +++ b/src/utils/RelativePosition.js @@ -9,7 +9,7 @@ import { ContentType, followRedone, getItem, - StructStore, ID, Doc, AbstractType, noAttributionsManager, // eslint-disable-line + StructStore, ID, Doc, YType, noAttributionsManager, // eslint-disable-line } from '../internals.js' import * as encoding from 'lib0/encoding' @@ -106,13 +106,13 @@ export const createRelativePositionFromJSON = json => new RelativePosition(json. export class AbsolutePosition { /** - * @param {AbstractType} type + * @param {YType} type * @param {number} index * @param {number} [assoc] */ constructor (type, index, assoc = 0) { /** - * @type {AbstractType} + * @type {YType} */ this.type = type /** @@ -124,7 +124,7 @@ export class AbsolutePosition { } /** - * @param {AbstractType} type + * @param {YType} type * @param {number} index * @param {number} [assoc] * @@ -133,7 +133,7 @@ export class AbsolutePosition { export const createAbsolutePosition = (type, index, assoc = 0) => new AbsolutePosition(type, index, assoc) /** - * @param {AbstractType} type + * @param {YType} type * @param {ID|null} item * @param {number} [assoc] * @@ -153,7 +153,7 @@ export const createRelativePosition = (type, item, assoc) => { /** * Create a relativePosition based on a absolute position. * - * @param {AbstractType} type The base type (e.g. YText or YArray). + * @param {YType} type The base type (e.g. YText or YArray). * @param {number} index The absolute position. * @param {number} [assoc] * @param {import('../utils/AttributionManager.js').AbstractAttributionManager} attributionManager @@ -304,7 +304,7 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc, followUndo if (!(right instanceof Item)) { return null } - type = /** @type {AbstractType} */ (right.parent) + type = /** @type {YType} */ (right.parent) if (type._item === null || !type._item.deleted) { index = attributionManager.contentLength(right) === 0 ? 0 : (res.diff + (assoc >= 0 ? 0 : 1)) // adjust position based on left association if necessary let n = right.left diff --git a/src/utils/Transaction.js b/src/utils/Transaction.js index ea34e571..8734aa26 100644 --- a/src/utils/Transaction.js +++ b/src/utils/Transaction.js @@ -9,10 +9,12 @@ import { Item, generateNewClientId, createID, - cleanupYTextAfterTransaction, - IdSet, UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line + iterateStructsByIdSet, + ContentFormat, + IdSet, UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractStruct, YEvent, Doc // eslint-disable-line } from '../internals.js' +import {YType} from '../ytype.js' import * as error from 'lib0/error' import * as map from 'lib0/map' import * as math from 'lib0/math' @@ -84,13 +86,13 @@ export class Transaction { * All types that were directly modified (property added or child * inserted/deleted). New types are not included in this Set. * Maps from type to parentSubs (`item.parentSub = null` for YArray) - * @type {Map>} + * @type {Map>} */ this.changed = new Map() /** * Stores the events for the types that observe also child elements. * It is mainly used by `observeDeep`. - * @type {Map>>} + * @type {Map>>} */ this.changedParentTypes = new Map() /** @@ -198,7 +200,7 @@ export const nextID = transaction => { * did not change, it was just added and we should not fire events for `type`. * * @param {Transaction} transaction - * @param {import('../utils/types.js').YType} type + * @param {YType} type * @param {string|null} parentSub */ export const addChangedTypeToTransaction = (transaction, type, parentSub) => { @@ -220,8 +222,8 @@ const tryToMergeWithLefts = (structs, pos) => { for (; i > 0; right = left, left = structs[--i - 1]) { if (left.deleted === right.deleted && left.constructor === right.constructor) { if (left.mergeWith(right)) { - if (right instanceof Item && right.parentSub !== null && /** @type {AbstractType} */ (right.parent)._map.get(right.parentSub) === right) { - /** @type {AbstractType} */ (right.parent)._map.set(right.parentSub, /** @type {Item} */ (left)) + if (right instanceof Item && right.parentSub !== null && /** @type {YType} */ (right.parent)._map.get(right.parentSub) === right) { + /** @type {YType} */ (right.parent)._map.set(right.parentSub, /** @type {Item} */ (left)) } continue } @@ -300,6 +302,200 @@ export const tryGc = (tr, idset, gcFilter) => { tryMerge(idset, tr.doc.store) } +/** + * @param {Transaction} transaction + * @param {Item | null} item + */ +const cleanupContextlessFormattingGap = (transaction, item) => { + if (!transaction.doc.cleanupFormatting) return 0 + // iterate until item.right is null or content + while (item && item.right && (item.right.deleted || !item.right.countable)) { + item = item.right + } + const attrs = new Set() + // iterate back until a content item is found + while (item && (item.deleted || !item.countable)) { + if (!item.deleted && item.content.constructor === ContentFormat) { + const key = /** @type {ContentFormat} */ (item.content).key + if (attrs.has(key)) { + item.delete(transaction) + transaction.cleanUps.add(item.id.client, item.id.clock, item.length) + } else { + attrs.add(key) + } + } + item = item.left + } +} + +/** + * @param {Map} currentAttributes + * @param {ContentFormat} format + * + * @private + * @function + */ +const updateCurrentAttributes = (currentAttributes, { key, value }) => { + if (value === null) { + currentAttributes.delete(key) + } else { + currentAttributes.set(key, value) + } +} + + +/** + * Call this function after string content has been deleted in order to + * clean up formatting Items. + * + * @param {Transaction} transaction + * @param {Item} start + * @param {Item|null} curr exclusive end, automatically iterates to the next Content Item + * @param {Map} startAttributes + * @param {Map} currAttributes + * @return {number} The amount of formatting Items deleted. + * + * @function + */ +export const cleanupFormattingGap = (transaction, start, curr, startAttributes, currAttributes) => { + if (!transaction.doc.cleanupFormatting) return 0 + /** + * @type {Item|null} + */ + let end = start + /** + * @type {Map} + */ + const endFormats = map.create() + while (end && (!end.countable || end.deleted)) { + if (!end.deleted && end.content.constructor === ContentFormat) { + const cf = /** @type {ContentFormat} */ (end.content) + endFormats.set(cf.key, cf) + } + end = end.right + } + let cleanups = 0 + let reachedCurr = false + while (start !== end) { + if (curr === start) { + reachedCurr = true + } + if (!start.deleted) { + const content = start.content + switch (content.constructor) { + case ContentFormat: { + const { key, value } = /** @type {ContentFormat} */ (content) + const startAttrValue = startAttributes.get(key) ?? null + if (endFormats.get(key) !== content || startAttrValue === value) { + // Either this format is overwritten or it is not necessary because the attribute already existed. + start.delete(transaction) + transaction.cleanUps.add(start.id.client, start.id.clock, start.length) + cleanups++ + if (!reachedCurr && (currAttributes.get(key) ?? null) === value && startAttrValue !== value) { + if (startAttrValue === null) { + currAttributes.delete(key) + } else { + currAttributes.set(key, startAttrValue) + } + } + } + if (!reachedCurr && !start.deleted) { + updateCurrentAttributes(currAttributes, /** @type {ContentFormat} */ (content)) + } + break + } + } + } + start = /** @type {Item} */ (start.right) + } + return cleanups +} + + +/** + * This function is experimental and subject to change / be removed. + * + * Ideally, we don't need this function at all. Formatting attributes should be cleaned up + * automatically after each change. This function iterates twice over the complete YText type + * and removes unnecessary formatting attributes. This is also helpful for testing. + * + * This function won't be exported anymore as soon as there is confidence that the YText type works as intended. + * + * @param {YType} type + * @return {number} How many formatting attributes have been cleaned up. + */ +export const cleanupYTextFormatting = type => { + if (!type.doc?.cleanupFormatting) return 0 + let res = 0 + transact(/** @type {Doc} */ (type.doc), transaction => { + let start = /** @type {Item} */ (type._start) + let end = type._start + let startAttributes = map.create() + const currentAttributes = map.copy(startAttributes) + while (end) { + if (end.deleted === false) { + switch (end.content.constructor) { + case ContentFormat: + updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (end.content)) + break + default: + res += cleanupFormattingGap(transaction, start, end, startAttributes, currentAttributes) + startAttributes = map.copy(currentAttributes) + start = end + break + } + } + end = end.right + } + }) + return res +} + + +/** + * This will be called by the transaction once the event handlers are called to potentially cleanup + * formatting attributes. + * + * @param {Transaction} transaction + */ +export const cleanupYTextAfterTransaction = transaction => { + /** + * @type {Set} + */ + const needFullCleanup = new Set() + // check if another formatting item was inserted + const doc = transaction.doc + iterateStructsByIdSet(transaction, transaction.insertSet, (item) => { + if ( + !item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat && item.constructor !== GC + ) { + needFullCleanup.add(/** @type {any} */ (item).parent) + } + }) + // cleanup in a new transaction + transact(doc, (t) => { + iterateStructsByIdSet(transaction, transaction.deleteSet, item => { + if (item instanceof GC || !(/** @type {YType} */ (item.parent)._hasFormatting) || needFullCleanup.has(/** @type {YType} */ (item.parent))) { + return + } + const parent = /** @type {YType} */ (item.parent) + if (item.content.constructor === ContentFormat) { + needFullCleanup.add(parent) + } else { + // If no formatting attribute was inserted or deleted, we can make due with contextless + // formatting cleanups. + // Contextless: it is not necessary to compute currentAttributes for the affected position. + cleanupContextlessFormattingGap(t, item) + } + }) + // If a formatting item was inserted, we simply clean the whole type. + // We need to compute currentAttributes for the current position anyway. + for (const yText of needFullCleanup) { + cleanupYTextFormatting(yText) + } + }) +} + /** * @param {Array} transactionCleanups * @param {number} i @@ -352,7 +548,12 @@ const cleanupTransactions = (transactionCleanups, i) => { .sort((event1, event2) => event1.path.length - event2.path.length) // We don't need to check for events.length // because we know it has at least one element - callEventHandlerListeners(type._dEH, events, transaction) + + /** + * @type {YEvent} + */ + const deepEventHandler = events.find(event => event.target === type) || new YEvent(type, transaction, new Set(null)) + callEventHandlerListeners(type._dEH, deepEventHandler, transaction) } }) }) diff --git a/src/utils/UndoManager.js b/src/utils/UndoManager.js index 6857eb2f..41ce2427 100644 --- a/src/utils/UndoManager.js +++ b/src/utils/UndoManager.js @@ -8,7 +8,7 @@ import { isParentOf, followRedone, getItemCleanStart, - YEvent, Transaction, Doc, Item, GC, IdSet, AbstractType // eslint-disable-line + YEvent, Transaction, Doc, Item, GC, IdSet, YType // eslint-disable-line } from '../internals.js' import * as time from 'lib0/time' @@ -273,7 +273,7 @@ export class UndoManager extends ObservableV2 { ytypes.forEach(ytype => { if (!tmpSet.has(ytype)) { tmpSet.add(ytype) - if (ytype instanceof AbstractType ? ytype.doc !== this.doc : ytype !== this.doc) logging.warn('[yjs#509] Not same Y.Doc') // use MultiDocUndoManager instead. also see https://github.com/yjs/yjs/issues/509 + if (ytype instanceof YType ? ytype.doc !== this.doc : ytype !== this.doc) logging.warn('[yjs#509] Not same Y.Doc') // use MultiDocUndoManager instead. also see https://github.com/yjs/yjs/issues/509 this.scope.push(ytype) } }) diff --git a/src/utils/YEvent.js b/src/utils/YEvent.js index 68575d5d..9471fe15 100644 --- a/src/utils/YEvent.js +++ b/src/utils/YEvent.js @@ -2,7 +2,7 @@ import { diffIdSet, mergeIdSets, noAttributionsManager, - Doc, AbstractAttributionManager, Item, AbstractType, Transaction, AbstractStruct, // eslint-disable-line + YType, Doc, AbstractAttributionManager, Item, Transaction, AbstractStruct, // eslint-disable-line createAbsolutePositionFromRelativePosition, createRelativePosition, AbsolutePosition @@ -18,19 +18,19 @@ import * as set from 'lib0/set' */ export class YEvent { /** - * @param {AbstractType} target The changed type. + * @param {YType} target The changed type. * @param {Transaction} transaction * @param {Set?} subs The keys that changed */ constructor (target, transaction, subs) { /** * The type on which this event was created on. - * @type {AbstractType} + * @type {YType} */ this.target = target /** * The current target on which the observe callback is called. - * @type {AbstractType} + * @type {YType} */ this.currentTarget = target /** @@ -39,11 +39,11 @@ export class YEvent { */ this.transaction = transaction /** - * @type {delta.Delta|null} + * @type {import('../ytype.js').DeltaConfTypesToDelta|null} */ this._delta = null /** - * @type {import('../types/AbstractType.js').DeltaConfTypesToDeltaDelta|null} + * @type {delta.Delta|null} */ this._deltaDeep = null /** @@ -125,7 +125,7 @@ export class YEvent { /** * @todo this should be done only one in the transaction step * - * @type {Map>|null} + * @type {Map>|null} */ let modified = this.transaction.changed if (deep) { @@ -184,8 +184,8 @@ export class YEvent { * console.log(path) // might look like => [2, 'key1'] * child === type.get(path[0]).get(path[1]) * - * @param {AbstractType} parent - * @param {AbstractType} child target + * @param {YType} parent + * @param {YType} child target * @param {AbstractAttributionManager} am * @return {Array} Path to the target * @@ -205,7 +205,7 @@ export const getPathTo = (parent, child, am = noAttributionsManager) => { const apos = /** @type {AbsolutePosition} */ (createAbsolutePositionFromRelativePosition(createRelativePosition(parent, child._item.id), doc, false, am)) path.unshift(apos.index) } - child = /** @type {AbstractType} */ (child._item.parent) + child = /** @type {YType} */ (child._item.parent) } return path } diff --git a/src/utils/delta-helpers.js b/src/utils/delta-helpers.js index 9511d79b..c1be4a6a 100644 --- a/src/utils/delta-helpers.js +++ b/src/utils/delta-helpers.js @@ -5,7 +5,7 @@ import { diffIdSet, mergeIdSets, Item, - AbstractType, Doc, // eslint-disable-line + YType, Doc, // eslint-disable-line iterateStructsByIdSet } from '../internals.js' import * as delta from 'lib0/delta' @@ -28,12 +28,12 @@ export const diffDocsToDelta = (v1, v2, { am = createAttributionManagerFromDiff( const deletesOnly = diffIdSet(deleteDiff, insertDiff) const itemsToRender = mergeIdSets([insertsOnly, deleteDiff]) /** - * @type {Map>} + * @type {Map>} */ const changedTypes = new Map() iterateStructsByIdSet(tr, itemsToRender, /** @param {any} item */ item => { while (item instanceof Item) { - const parent = /** @type {AbstractType} */ (item.parent) + const parent = /** @type {YType} */ (item.parent) const conf = map.setIfUndefined(changedTypes, parent, set.create) if (conf.has(item.parentSub)) break // has already been marked as modified conf.add(item.parentSub) diff --git a/src/utils/isParentOf.js b/src/utils/isParentOf.js index e26d4eb1..cdf8ff62 100644 --- a/src/utils/isParentOf.js +++ b/src/utils/isParentOf.js @@ -1,9 +1,9 @@ -import { AbstractType, Item } from '../internals.js' // eslint-disable-line +import { Item } from '../internals.js' // eslint-disable-line /** * Check if `parent` is a parent of `child`. * - * @param {import('../utils/types.js').YType} parent + * @param {import('../ytype.js').YType} parent * @param {Item|null} child * @return {Boolean} Whether `parent` is a parent of `child`. * @@ -15,7 +15,7 @@ export const isParentOf = (parent, child) => { if (child.parent === parent) { return true } - child = /** @type {AbstractType} */ (child.parent)._item + child = /** @type {import('../ytype.js').YType} */ (child.parent)._item } return false } diff --git a/src/utils/logging.js b/src/utils/logging.js index 989ac488..dee4e3b4 100644 --- a/src/utils/logging.js +++ b/src/utils/logging.js @@ -1,5 +1,5 @@ import { - AbstractType // eslint-disable-line + YType // eslint-disable-line } from '../internals.js' /** @@ -7,7 +7,7 @@ import { * * Do not use in productive systems as the output can be immense! * - * @param {AbstractType} type + * @param {YType} type */ export const logType = type => { const res = [] diff --git a/src/utils/types.js b/src/utils/types.js index 725581ca..3eb47b1f 100644 --- a/src/utils/types.js +++ b/src/utils/types.js @@ -1,28 +1,3 @@ /** - * @typedef {import('../types/YArray.js').YArray - * | import('../types/YMap.js').YMap - * | import('../types/YText.js').YText - * | import('../types/YXmlFragment.js').YXmlFragment - * | import('../types/YXmlElement.js').YXmlElement - * | import('../types/YXmlHook.js').YXmlHook - * | import('../types/YXmlText.js').YXmlText} YValueType - */ - -/** - * @typedef {Object|Array|number|null|string|Uint8Array|BigInt|YValueType} YValue - */ - -/** - * @typedef {import('../types/AbstractType.js').AbstractType} YType - */ - -/** - * @typedef {typeof import('../types/YArray.js').YArray - * | typeof import('../types/YMap.js').YMap - * | typeof import('../types/YText.js').YText - * | typeof import('../types/YXmlFragment.js').YXmlFragment - * | typeof import('../types/YXmlElement.js').YXmlElement - * | typeof import('../types/YXmlHook.js').YXmlHook - * | typeof import('../types/YXmlText.js').YXmlText - * | typeof import('../types/AbstractType.js').AbstractType} YTypeConstructors + * @typedef {Object|Array|number|null|string|Uint8Array|BigInt|import('./').YType} YValue */ diff --git a/src/utils/updates.js b/src/utils/updates.js index b6db8b6b..5f026ad2 100644 --- a/src/utils/updates.js +++ b/src/utils/updates.js @@ -33,8 +33,6 @@ import { UpdateEncoderV1, UpdateEncoderV2, writeIdSet, - YXmlElement, - YXmlHook, createIdSet } from '../internals.js' @@ -617,12 +615,8 @@ const createObfuscator = ({ formatting = true, subdocs = true, yxml = true } = { case ContentType: { if (yxml) { const type = /** @type {ContentType} */ (content).type - if (type instanceof YXmlElement) { - type.nodeName = map.setIfUndefined(nodeNameCache, type.nodeName, () => 'node-' + i) - } - // @ts-ignore - if (type instanceof YXmlHook) { - type.hookName = map.setIfUndefined(nodeNameCache, type.hookName, () => 'hook-' + i) + if (type.name != null) { + type.name = map.setIfUndefined(nodeNameCache, type.nodeName, () => 'typename-' + i) } } break diff --git a/src/ytype.js b/src/ytype.js new file mode 100644 index 00000000..04c9c485 --- /dev/null +++ b/src/ytype.js @@ -0,0 +1,2065 @@ +import { + cleanupFormattingGap, + createIdSet, + removeEventHandlerListener, + callEventHandlerListeners, + addEventHandlerListener, + createEventHandler, + getState, + isVisible, + ContentType, + createID, + ContentAny, + ContentFormat, + ContentBinary, + ContentJSON, + ContentDeleted, + ContentString, + ContentEmbed, + getItemCleanStart, + noAttributionsManager, + transact, + ContentDoc, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, createAttributionFromAttributionItems, AbstractAttributionManager // eslint-disable-line +} from './internals.js' + +import * as contentType from './structs/ContentType.js' + +import * as traits from 'lib0/traits' +import * as delta from 'lib0/delta' +import * as array from 'lib0/array' +import * as map from 'lib0/map' +import * as iterator from 'lib0/iterator' +import * as error from 'lib0/error' +import * as math from 'lib0/math' +import * as log from 'lib0/logging' +import * as object from 'lib0/object' + +/** + * @typedef {Object|Array|number|null|string|Uint8Array|BigInt|YType} YValue + */ + +/** + * https://docs.yjs.dev/getting-started/working-with-shared-types#caveats + */ +export const warnPrematureAccess = () => { log.warn('Invalid access: Add Yjs type to a document before reading data.') } + +const maxSearchMarker = 80 + +/** + * A unique timestamp that identifies each marker. + * + * Time is relative,.. this is more like an ever-increasing clock. + * + * @type {number} + */ +let globalSearchMarkerTimestamp = 0 + +export class ItemTextListPosition { + /** + * @param {Item|null} left + * @param {Item|null} right + * @param {number} index + * @param {Map} currentAttributes + * @param {AbstractAttributionManager} am + */ + constructor (left, right, index, currentAttributes, am) { + this.left = left + this.right = right + this.index = index + this.currentAttributes = currentAttributes + this.am = am + } + + /** + * Only call this if you know that this.right is defined + */ + forward () { + if (this.right === null) { + error.unexpectedCase() + } + switch (this.right.content.constructor) { + case ContentFormat: + if (!this.right.deleted) { + updateCurrentAttributes(this.currentAttributes, /** @type {ContentFormat} */ (this.right.content)) + } + break + default: + this.index += this.am.contentLength(this.right) + break + } + this.left = this.right + this.right = this.right.right + } + + /** + * @param {Transaction} transaction + * @param {YType} parent + * @param {number} length + * @param {Object} attributes + * + * @function + */ + formatText (transaction, parent, length, attributes) { + const doc = transaction.doc + const ownClientId = doc.clientID + minimizeAttributeChanges(this, attributes) + const negatedAttributes = insertAttributes(transaction, parent, this, attributes) + // iterate until first non-format or null is found + // delete all formats with attributes[format.key] != null + // also check the attributes after the first non-format as we do not want to insert redundant negated attributes there + // eslint-disable-next-line no-labels + iterationLoop: while ( + this.right !== null && + (length > 0 || + ( + negatedAttributes.size > 0 && + ((this.right.deleted && this.am.contentLength(this.right) === 0) || this.right.content.constructor === ContentFormat) + ) + ) + ) { + switch (this.right.content.constructor) { + case ContentFormat: { + if (!this.right.deleted) { + const { key, value } = /** @type {ContentFormat} */ (this.right.content) + const attr = attributes[key] + if (attr !== undefined) { + if (equalAttrs(attr, value)) { + negatedAttributes.delete(key) + } else { + if (length === 0) { + // no need to further extend negatedAttributes + // eslint-disable-next-line no-labels + break iterationLoop + } + negatedAttributes.set(key, value) + } + this.right.delete(transaction) + } else { + this.currentAttributes.set(key, value) + } + } + break + } + default: { + const item = this.right + const rightLen = this.am.contentLength(item) + if (length < rightLen) { + /** + * @type {Array>} + */ + const contents = [] + this.am.readContent(contents, item.id.client, item.id.clock, item.deleted, item.content, 0) + let i = 0 + for (; i < contents.length && length > 0; i++) { + const c = contents[i] + if ((!c.deleted || c.attrs != null) && c.content.isCountable()) { + length -= c.content.getLength() + } + } + if (length < 0 || (length === 0 && i !== contents.length)) { + const c = contents[--i] + getItemCleanStart(transaction, createID(item.id.client, c.clock + c.content.getLength() + length)) + } + } else { + length -= rightLen + } + break + } + } + this.forward() + } + // Quill just assumes that the editor starts with a newline and that it always + // ends with a newline. We only insert that newline when a new newline is + // inserted - i.e when length is bigger than type.length + if (length > 0) { + let newlines = '' + for (; length > 0; length--) { + newlines += '\n' + } + this.right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), this.left, this.left && this.left.lastId, this.right, this.right && this.right.id, parent, null, new ContentString(newlines)) + this.right.integrate(transaction, 0) + this.forward() + } + insertNegatedAttributes(transaction, parent, this, negatedAttributes) + } +} + +/** + * @param {Transaction} transaction + * @param {ItemTextListPosition} pos + * @param {number} count steps to move forward + * @return {ItemTextListPosition} + * + * @private + * @function + */ +const findNextPosition = (transaction, pos, count) => { + while (pos.right !== null && count > 0) { + switch (pos.right.content.constructor) { + case ContentFormat: + if (!pos.right.deleted) { + updateCurrentAttributes(pos.currentAttributes, /** @type {ContentFormat} */ (pos.right.content)) + } + break + default: + if (!pos.right.deleted) { + if (count < pos.right.length) { + // split right + getItemCleanStart(transaction, createID(pos.right.id.client, pos.right.id.clock + count)) + } + pos.index += pos.right.length + count -= pos.right.length + } + break + } + pos.left = pos.right + pos.right = pos.right.right + // pos.forward() - we don't forward because that would halve the performance because we already do the checks above + } + return pos +} + +/** + * @param {Transaction} transaction + * @param {YType} parent + * @param {number} index + * @param {boolean} useSearchMarker + * @return {ItemTextListPosition} + * + * @private + * @function + */ +const findPosition = (transaction, parent, index, useSearchMarker) => { + const currentAttributes = new Map() + const marker = useSearchMarker ? findMarker(parent, index) : null + if (marker) { + const pos = new ItemTextListPosition(marker.p.left, marker.p, marker.index, currentAttributes, noAttributionsManager) + return findNextPosition(transaction, pos, index - marker.index) + } else { + const pos = new ItemTextListPosition(null, parent._start, 0, currentAttributes, noAttributionsManager) + return findNextPosition(transaction, pos, index) + } +} + +/** + * Negate applied formats + * + * @param {Transaction} transaction + * @param {YType} parent + * @param {ItemTextListPosition} currPos + * @param {Map} negatedAttributes + * + * @private + * @function + */ +const insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes) => { + // check if we really need to remove attributes + while ( + currPos.right !== null && ( + (currPos.right.deleted && (currPos.am === noAttributionsManager || currPos.am.contentLength(currPos.right) === 0)) || ( + currPos.right.content.constructor === ContentFormat && + equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (currPos.right.content).key), /** @type {ContentFormat} */ (currPos.right.content).value) + ) + ) + ) { + if (!currPos.right.deleted) { + negatedAttributes.delete(/** @type {ContentFormat} */ (currPos.right.content).key) + } + currPos.forward() + } + const doc = transaction.doc + const ownClientId = doc.clientID + negatedAttributes.forEach((val, key) => { + const left = currPos.left + const right = currPos.right + const nextFormat = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val)) + nextFormat.integrate(transaction, 0) + currPos.right = nextFormat + currPos.forward() + }) +} + +/** + * @param {Map} currentAttributes + * @param {ContentFormat} format + * + * @private + * @function + */ +const updateCurrentAttributes = (currentAttributes, format) => { + const { key, value } = format + if (value === null) { + currentAttributes.delete(key) + } else { + currentAttributes.set(key, value) + } +} + +/** + * @param {ItemTextListPosition} currPos + * @param {Object} attributes + * + * @private + * @function + */ +const minimizeAttributeChanges = (currPos, attributes) => { + // go right while attributes[right.key] === right.value (or right is deleted) + while (true) { + if (currPos.right === null) { + break + } else if (currPos.right.deleted ? (currPos.am.contentLength(currPos.right) === 0) : (!currPos.right.deleted && currPos.right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (currPos.right.content)).key] ?? null, /** @type {ContentFormat} */ (currPos.right.content).value))) { + // + } else { + break + } + currPos.forward() + } +} + +/** + * @param {Transaction} transaction + * @param {YType} parent + * @param {ItemTextListPosition} currPos + * @param {Object} attributes + * @return {Map} + * + * @private + * @function + **/ +const insertAttributes = (transaction, parent, currPos, attributes) => { + const doc = transaction.doc + const ownClientId = doc.clientID + const negatedAttributes = new Map() + // insert format-start items + for (const key in attributes) { + const val = attributes[key] + const currentVal = currPos.currentAttributes.get(key) ?? null + if (!equalAttrs(currentVal, val)) { + // save negated attribute (set null if currentVal undefined) + negatedAttributes.set(key, currentVal) + const { left, right } = currPos + currPos.right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val)) + currPos.right.integrate(transaction, 0) + currPos.forward() + } + } + return negatedAttributes +} + +/** + * @param {Transaction} transaction + * @param {YType} parent + * @param {ItemTextListPosition} currPos + * @param {string|object|YType} text + * @param {Object} attributes + * + * @private + * @function + **/ +export const insertText = (transaction, parent, currPos, text, attributes) => { + currPos.currentAttributes.forEach((_val, key) => { + if (attributes[key] === undefined) { + attributes[key] = null + } + }) + const doc = transaction.doc + const ownClientId = doc.clientID + minimizeAttributeChanges(currPos, attributes) + const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes) + // insert content + const content = text.constructor === String ? new ContentString(/** @type {string} */ (text)) : (text instanceof YType ? new ContentType(text) : new ContentEmbed(text)) + let { left, right, index } = currPos + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength()) + } + right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content) + right.integrate(transaction, 0) + currPos.right = right + currPos.index = index + currPos.forward() + insertNegatedAttributes(transaction, parent, currPos, negatedAttributes) +} + +/** + * @param {Transaction} transaction + * @param {ItemTextListPosition} currPos + * @param {number} length + * @return {ItemTextListPosition} + * + * @private + * @function + */ +export const deleteText = (transaction, currPos, length) => { + const startLength = length + const startAttrs = map.copy(currPos.currentAttributes) + const start = currPos.right + while (length > 0 && currPos.right !== null) { + if (!currPos.right.deleted) { + switch (currPos.right.content.constructor) { + case ContentType: + case ContentEmbed: + case ContentString: + if (length < currPos.right.length) { + getItemCleanStart(transaction, createID(currPos.right.id.client, currPos.right.id.clock + length)) + } + length -= currPos.right.length + currPos.right.delete(transaction) + break + } + } else if (currPos.am !== noAttributionsManager) { + const item = currPos.right + /** + * @type {Array>} + */ + const contents = [] + currPos.am.readContent(contents, item.id.client, item.id.clock, true, item.content, 0) + for (let i = 0; i < contents.length; i++) { + const c = contents[i] + if (c.content.isCountable() && c.attrs != null) { + // deleting already deleted content. store that information in a meta property, but do + // nothing + const contentLen = math.min(c.content.getLength(), length) + map.setIfUndefined(transaction.meta, 'attributedDeletes', createIdSet).add(item.id.client, c.clock, contentLen) + length -= contentLen + } + } + const lastContent = contents.length > 0 ? contents[contents.length - 1] : null + const nextItemClock = item.id.clock + item.length + const nextContentClock = lastContent != null ? lastContent.clock + lastContent.content.getLength() : nextItemClock + if (nextContentClock < nextItemClock) { + getItemCleanStart(transaction, createID(item.id.client, nextContentClock)) + } + } + currPos.forward() + } + if (start) { + cleanupFormattingGap(transaction, start, currPos.right, startAttrs, currPos.currentAttributes) + } + const parent = /** @type {YType} */ (/** @type {Item} */ (currPos.left || currPos.right).parent) + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, currPos.index, -startLength + length) + } + return currPos +} + +export class ArraySearchMarker { + /** + * @param {Item} p + * @param {number} index + */ + constructor (p, index) { + p.marker = true + this.p = p + this.index = index + this.timestamp = globalSearchMarkerTimestamp++ + } +} + +/** + * @param {ArraySearchMarker} marker + */ +const refreshMarkerTimestamp = marker => { marker.timestamp = globalSearchMarkerTimestamp++ } + +/** + * This is rather complex so this function is the only thing that should overwrite a marker + * + * @param {ArraySearchMarker} marker + * @param {Item} p + * @param {number} index + */ +const overwriteMarker = (marker, p, index) => { + marker.p.marker = false + marker.p = p + p.marker = true + marker.index = index + marker.timestamp = globalSearchMarkerTimestamp++ +} + +/** + * @param {Array} searchMarker + * @param {Item} p + * @param {number} index + */ +const markPosition = (searchMarker, p, index) => { + if (searchMarker.length >= maxSearchMarker) { + // override oldest marker (we don't want to create more objects) + const marker = searchMarker.reduce((a, b) => a.timestamp < b.timestamp ? a : b) + overwriteMarker(marker, p, index) + return marker + } else { + // create new marker + const pm = new ArraySearchMarker(p, index) + searchMarker.push(pm) + return pm + } +} + +/** + * Search marker help us to find positions in the associative array faster. + * + * They speed up the process of finding a position without much bookkeeping. + * + * A maximum of `maxSearchMarker` objects are created. + * + * This function always returns a refreshed marker (updated timestamp) + * + * @param {YType} yarray + * @param {number} index + */ +export const findMarker = (yarray, index) => { + if (yarray._start === null || index === 0 || yarray._searchMarker === null) { + return null + } + const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b) + let p = yarray._start + let pindex = 0 + if (marker !== null) { + p = marker.p + pindex = marker.index + refreshMarkerTimestamp(marker) // we used it, we might need to use it again + } + // iterate to right if possible + while (p.right !== null && pindex < index) { + if (!p.deleted && p.countable) { + if (index < pindex + p.length) { + break + } + pindex += p.length + } + p = p.right + } + // iterate to left if necessary (might be that pindex > index) + while (p.left !== null && pindex > index) { + p = p.left + if (!p.deleted && p.countable) { + pindex -= p.length + } + } + // we want to make sure that p can't be merged with left, because that would screw up everything + // in that cas just return what we have (it is most likely the best marker anyway) + // iterate to left until p can't be merged with left + while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) { + p = p.left + if (!p.deleted && p.countable) { + pindex -= p.length + } + } + if (marker !== null && math.abs(marker.index - pindex) < /** @type {any} */ (p.parent).length / maxSearchMarker) { + // adjust existing marker + overwriteMarker(marker, p, pindex) + return marker + } else { + // create new marker + return markPosition(yarray._searchMarker, p, pindex) + } +} + +/** + * Update markers when a change happened. + * + * This should be called before doing a deletion! + * + * @param {Array} searchMarker + * @param {number} index + * @param {number} len If insertion, len is positive. If deletion, len is negative. + */ +export const updateMarkerChanges = (searchMarker, index, len) => { + for (let i = searchMarker.length - 1; i >= 0; i--) { + const m = searchMarker[i] + if (len > 0) { + /** + * @type {Item|null} + */ + let p = m.p + p.marker = false + // Ideally we just want to do a simple position comparison, but this will only work if + // search markers don't point to deleted items for formats. + // Iterate marker to prev undeleted countable position so we know what to do when updating a position + while (p && (p.deleted || !p.countable)) { + p = p.left + if (p && !p.deleted && p.countable) { + // adjust position. the loop should break now + m.index -= p.length + } + } + if (p === null || p.marker === true) { + // remove search marker if updated position is null or if position is already marked + searchMarker.splice(i, 1) + continue + } + m.p = p + p.marker = true + } + if (index < m.index || (len > 0 && index === m.index)) { // a simple index <= m.index check would actually suffice + m.index = math.max(index, m.index + len) + } + } +} + +/** + * Accumulate all (list) children of a type and return them as an Array. + * + * @param {YType} t + * @return {Array} + */ +export const getTypeChildren = t => { + t.doc ?? warnPrematureAccess() + let s = t._start + const arr = [] + while (s) { + arr.push(s) + s = s.right + } + return arr +} + +/** + * Call event listeners with an event. This will also add an event to all + * parents (for `.observeDeep` handlers). + * + * @param {YType} type + * @param {Transaction} transaction + * @param {YEvent} event + */ +export const callTypeObservers = (type, transaction, event) => { + const changedType = type + const changedParentTypes = transaction.changedParentTypes + while (true) { + // @ts-ignore + map.setIfUndefined(changedParentTypes, type, () => []).push(event) + if (type._item === null) { + break + } + type = /** @type {YType} */ (type._item.parent) + } + callEventHandlerListeners(/** @type {any} */ (changedType._eH), event, transaction) +} + +/** + * Abstract Yjs Type class + * @template {delta.DeltaConf} [DConf=any] + */ +export class YType { + /** + * @param {delta.DeltaConfGetName?} name + */ + constructor (name = null) { + /** + * @type {delta.DeltaConfGetName} + */ + this.name = /** @type {delta.DeltaConfGetName} */ (name) + /** + * @type {Item|null} + */ + this._item = null + /** + * @type {Map} + */ + this._map = new Map() + /** + * @type {Item|null} + */ + this._start = null + /** + * @type {Doc|null} + */ + this.doc = null + this._length = 0 + /** + * Event handlers + * @type {EventHandler>,Transaction>} + */ + this._eH = createEventHandler() + /** + * Deep event handlers + * @type {EventHandler,Transaction>} + */ + this._dEH = createEventHandler() + /** + * @type {null | Array} + */ + this._searchMarker = null + /** + * @type {delta.DeltaBuilder} + * @private + */ + this._content = /** @type {delta.DeltaBuilderAny} */ (delta.create()) + this._legacyTypeRef = this.name == null ? contentType.YXmlFragmentRefID : contentType.YXmlElementRefID + /** + * @type {Array|null} + */ + this._searchMarker = [] + /** + * Whether this YText contains formatting attributes. + * This flag is updated when a formatting item is integrated (see ContentFormat.integrate) + */ + this._hasFormatting = false + } + + get length () { + this.doc ?? warnPrematureAccess() + return this._length + } + + + /** + * Returns a fresh delta that can be used to change this YType. + * @type {delta.DeltaBuilder>} + */ + get change () { + return /** @type {any} */ (delta.create()) + } + + /** + * @return {YType?} + */ + get parent () { + return /** @type {YType?} */ (this._item ? this._item.parent : null) + } + + /** + * 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) { + this.doc = y + this._item = item + if (this._prelim) { + this.applyDelta(this._prelim) + this._prelim = null + } + } + + /** + * @return {YType} + */ + _copy () { + return new YType(this.name) + } + + /** + * Creates YEvent and calls all type observers. + * Must be implemented by each type. + * + * @param {Transaction} transaction + * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. + */ + _callObserver (transaction, parentSubs) { + const event = new YEvent(/** @type {any} */ (this), transaction, parentSubs) + callTypeObservers(/** @type {any} */ (this), transaction, event) + if (!transaction.local && this._searchMarker) { + this._searchMarker.length = 0 + } + // If a remote change happened, we try to cleanup potential formatting duplicates. + if (!transaction.local && this._hasFormatting) { + transaction._needFormattingCleanup = true + } + } + + /** + * Observe all events that are created on this type. + * + * @template {(target: YEvent>, tr: Transaction) => void} F + * @param {F} f Observer function + * @return {F} + */ + observe (f) { + addEventHandlerListener(this._eH, f) + return f + } + + /** + * Observe all events that are created by this type and its children. + * + * @template {function(YEvent,Transaction):void} F + * @param {F} f Observer function + * @return {F} + */ + observeDeep (f) { + addEventHandlerListener(this._dEH, f) + return f + } + + /** + * Unregister an observer function. + * + * @param {(type:YEvent>,tr:Transaction)=>void} f Observer function + */ + unobserve (f) { + removeEventHandlerListener(this._eH, f) + } + + /** + * Unregister an observer function. + * + * @param {function(YEvent,Transaction):void} f Observer function + */ + unobserveDeep (f) { + removeEventHandlerListener(this._dEH, f) + } + + /** + * Render the difference to another ydoc (which can be empty) and highlight the differences with + * attributions. + * + * Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the + * attribution `{ isDeleted: true, .. }`. + * + * @template {boolean} [Deep=false] + * + * @param {AbstractAttributionManager} am + * @param {Object} [opts] + * @param {import('./utils/IdSet.js').IdSet?} [opts.itemsToRender] + * @param {boolean} [opts.retainInserts] - if true, retain rendered inserts with attributions + * @param {boolean} [opts.retainDeletes] - if true, retain rendered+attributed deletes only + * @param {import('./utils/IdSet.js').IdSet?} [opts.deletedItems] - used for computing prevItem in attributes + * @param {Map>|null} [opts.modified] - set of types that should be rendered as modified children + * @param {Deep} [opts.deep] - render child types as delta + * @return {Deep extends true ? delta.Delta : delta.Delta>} The Delta representation of this type. + * + * @public + */ + getContent (am = noAttributionsManager, opts = {}) { + const { itemsToRender = null, retainInserts = false, retainDeletes = false, deletedItems = null, modified = null, deep = false } = opts + const renderAttrs = modified?.get(this) || null + const renderChildren = !!(modified == null || modified.get(this)?.has(null)) + /** + * @type {delta.DeltaBuilderAny} + */ + const d = /** @type {any} */ (delta.create(/** @type {any} */ (this).nodeName || null)) + const optsAll = modified == null ? opts : object.assign({}, opts, { modified: null }) + typeMapGetDelta(d, /** @type {any} */ (this), renderAttrs, am, deep, modified, deletedItems, itemsToRender, opts, optsAll) + if (renderChildren) { + /** + * @type {delta.FormattingAttributes} + */ + let currentAttributes = {} // saves all current attributes for insert + let usingCurrentAttributes = false + /** + * @type {delta.FormattingAttributes} + */ + let changedAttributes = {} // saves changed attributes for retain + let usingChangedAttributes = false + /** + * Logic for formatting attribute attribution + * Everything that comes after an formatting attribute is formatted by the user that created it. + * Two exceptions: + * - the user resets formatting to the previously known formatting that is not attributed + * - the user deletes a formatting attribute and hence restores the previously known formatting + * that is not attributed. + * @type {delta.FormattingAttributes} + */ + const previousUnattributedAttributes = {} // contains previously known unattributed formatting + /** + * @type {delta.FormattingAttributes} + */ + const previousAttributes = {} // The value before changes + /** + * @type {Array>} + */ + const cs = [] + for (let item = this._start; item !== null; cs.length = 0) { + if (itemsToRender != null) { + for (; item !== null && cs.length < 50; item = item.right) { + const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length) + let itemContent = rslice.length > 1 ? item.content.copy() : item.content + for (let ir = 0; ir < rslice.length; ir++) { + const idrange = rslice[ir] + const content = itemContent + if (ir !== rslice.length - 1) { + itemContent = itemContent.splice(idrange.len) + } + am.readContent(cs, item.id.client, idrange.clock, item.deleted, content, idrange.exists ? 2 : 0) + } + } + } else { + for (; item !== null && cs.length < 50; item = item.right) { + am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1) + } + } + for (let i = 0; i < cs.length; i++) { + const c = cs[i] + // render (attributed) content even if it was deleted + const renderContent = c.render && (!c.deleted || c.attrs != null) + // content that was just deleted. It is not rendered as an insertion, because it doesn't + // have any attributes. + const renderDelete = c.render && c.deleted + // existing content that should be retained, only adding changed attributes + const retainContent = !c.render && (!c.deleted || c.attrs != null) + const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : null + switch (c.content.constructor) { + case ContentDeleted: { + if (renderDelete) d.delete(c.content.getLength()) + break + } + case ContentString: + if (renderContent) { + d.usedAttributes = currentAttributes + usingCurrentAttributes = true + if (c.deleted ? retainDeletes : retainInserts) { + d.retain(/** @type {ContentString} */ (c.content).str.length, null, attribution ?? {}) + } else { + d.insert(/** @type {ContentString} */ (c.content).str, null, attribution) + } + } else if (renderDelete) { + d.delete(c.content.getLength()) + } else if (retainContent) { + d.usedAttributes = changedAttributes + usingChangedAttributes = true + d.retain(c.content.getLength()) + } + break + case ContentEmbed: + case ContentAny: + case ContentJSON: + case ContentType: + case ContentBinary: + if (renderContent) { + d.usedAttributes = currentAttributes + usingCurrentAttributes = true + if (c.deleted ? retainDeletes : retainInserts) { + d.retain(c.content.getLength(), null, attribution ?? {}) + } else if (deep && c.content.constructor === ContentType) { + d.insert([/** @type {any} */(c.content).type.getContent(am, optsAll)], null, attribution) + } else { + d.insert(c.content.getContent(), null, attribution) + } + } else if (renderDelete) { + d.delete(1) + } else if (retainContent) { + if (c.content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (c.content).type)) { + // @todo use current transaction instead + d.modify(/** @type {any} */ (c.content).type.getContent(am, opts)) + } else { + d.usedAttributes = changedAttributes + usingChangedAttributes = true + d.retain(1) + } + } + break + case ContentFormat: { + const { key, value } = /** @type {ContentFormat} */ (c.content) + const currAttrVal = currentAttributes[key] ?? null + if (attribution != null && (c.deleted || !object.hasProperty(previousUnattributedAttributes, key))) { + previousUnattributedAttributes[key] = c.deleted ? value : currAttrVal + } + // @todo write a function "updateCurrentAttributes" and "updateChangedAttributes" + // # Update Attributes + if (renderContent || renderDelete) { + // create fresh references + if (usingCurrentAttributes) { + currentAttributes = object.assign({}, currentAttributes) + usingCurrentAttributes = false + } + if (usingChangedAttributes) { + usingChangedAttributes = false + changedAttributes = object.assign({}, changedAttributes) + } + } + if (renderContent || renderDelete) { + if (c.deleted) { + // content was deleted, but is possibly attributed + if (!equalAttrs(value, currAttrVal)) { // do nothing if nothing changed + if (equalAttrs(currAttrVal, previousAttributes[key] ?? null) && changedAttributes[key] !== undefined) { + delete changedAttributes[key] + } else { + changedAttributes[key] = currAttrVal + } + // current attributes doesn't change + previousAttributes[key] = value + } + } else { // !c.deleted + // content was inserted, and is possibly attributed + if (equalAttrs(value, currAttrVal)) { + // item.delete(transaction) + } else if (equalAttrs(value, previousAttributes[key] ?? null)) { + delete changedAttributes[key] + } else { + changedAttributes[key] = value + } + if (value == null) { + delete currentAttributes[key] + } else { + currentAttributes[key] = value + } + } + } else if (retainContent && !c.deleted) { + // fresh reference to currentAttributes only + if (usingCurrentAttributes) { + currentAttributes = object.assign({}, currentAttributes) + usingCurrentAttributes = false + } + if (usingChangedAttributes && changedAttributes[key] !== undefined) { + usingChangedAttributes = false + changedAttributes = object.assign({}, changedAttributes) + } + if (value == null) { + delete currentAttributes[key] + } else { + currentAttributes[key] = value + } + delete changedAttributes[key] + previousAttributes[key] = value + } + // # Update Attributions + if (attribution != null || object.hasProperty(previousUnattributedAttributes, key)) { + /** + * @type {import('./utils/AttributionManager.js').Attribution} + */ + const formattingAttribution = object.assign({}, d.usedAttribution) + const changedAttributedAttributes = /** @type {{ [key: string]: Array }} */ (formattingAttribution.format = object.assign({}, formattingAttribution.format ?? {})) + if (attribution == null || equalAttrs(previousUnattributedAttributes[key], currentAttributes[key] ?? null)) { + // an unattributed formatting attribute was found or an attributed formatting + // attribute was found that resets to the previous status + delete changedAttributedAttributes[key] + delete previousUnattributedAttributes[key] + } else { + const by = changedAttributedAttributes[key] = (changedAttributedAttributes[key]?.slice() ?? []) + by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? [])) + const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt) + if (attributedAt) formattingAttribution.formatAt = attributedAt + } + if (object.isEmpty(changedAttributedAttributes)) { + d.useAttribution(null) + } else if (attribution != null) { + const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt) + if (attributedAt != null) formattingAttribution.formatAt = attributedAt + d.useAttribution(formattingAttribution) + } + } + break + } + } + } + } + } + return /** @type {any} */ (d.done(false)) + } + + /** + * Render the difference to another ydoc (which can be empty) and highlight the differences with + * attributions. + * + * @param {AbstractAttributionManager} am + * @return {delta.Delta} + */ + getContentDeep (am = noAttributionsManager) { + return /** @type {any} */ (this.getContent(am, { deep: true })) + } + + /** + * Apply a {@link Delta} on this shared type. + * + * @param {delta.DeltaAny} d The changes to apply on this element. + * @param {AbstractAttributionManager} am + * + * @public + */ + applyDelta (d, am = noAttributionsManager) { + if (this.doc == null) { + (this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d) + } else { + // @todo this was moved here from ytext. Make this more generic + transact(this.doc, transaction => { + const currPos = new ItemTextListPosition(null, this._start, 0, new Map(), am) + for (const op of d.children) { + if (delta.$textOp.check(op)) { + insertText(transaction, /** @type {any} */ (this), currPos, op.insert, op.format || {}) + } else if (delta.$insertOp.check(op)) { + for (let i = 0; i < op.insert.length; i++) { + let ins = op.insert[i] + if (delta.$deltaAny.check(ins)) { + if (ins.name != null) { + const t = new YType(ins.name) + t.applyDelta(ins) + ins = t + } else { + error.unexpectedCase() + } + } + insertText(transaction, /** @type {any} */ (this), currPos, ins, op.format || {}) + } + } else if (delta.$retainOp.check(op)) { + currPos.formatText(transaction, /** @type {any} */ (this), op.retain, op.format || {}) + } else if (delta.$deleteOp.check(op)) { + deleteText(transaction, currPos, op.delete) + } else if (delta.$modifyOp.check(op)) { + if (currPos.right) { + /** @type {ContentType} */ (currPos.right.content).type.applyDelta(op.value) + } else { + error.unexpectedCase() + } + currPos.formatText(transaction, /** @type {any} */ (this), 1, op.format || {}) + } else { + error.unexpectedCase() + } + } + for (const op of d.attrs) { + if (delta.$setAttrOp.check(op)) { + typeMapSet(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key), op.value) + } else if (delta.$deleteOp.check(op)) { + typeMapDelete(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key)) + } else { + const sub = typeMapGet(/** @type {any} */ (this), /** @type {any} */ (op.key)) + if (!(sub instanceof YType)) { + error.unexpectedCase() + } + sub.applyDelta(op.value) + } + } + }) + } + } + + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {YType} + */ + clone () { + const cpy = this._copy() + cpy.applyDelta(this.getContentDeep()) + return cpy + } + + /** + * Removes all elements from this YMap. + */ + clearAttrs () { + let d = delta.create() + this.forEachAttr((_,key) => { + d.deleteAttr(/** @type {any} */ (key)) + }) + this.applyDelta(d) + } + + + /** + * Removes an attribute from this YXmlElement. + * + * @param {string} attributeName The attribute name that is to be removed. + * + * @public + */ + deleteAttr (attributeName) { + this.applyDelta(delta.create().deleteAttr(attributeName).done()) + } + + /** + * Sets or updates an attribute. + * + * @template {Exclude,symbol>} KEY + * + * @param {KEY} attributeName The attribute name that is to be set. + * @param {delta.DeltaConfGetAttrs[KEY]} attributeValue The attribute value that is to be set. + * + * @public + */ + setAttribute (attributeName, attributeValue) { + this.applyDelta(delta.create().setAttr(attributeName, attributeValue).done()) + } + + /** + * Returns an attribute value that belongs to the attribute name. + * + * @template {Exclude,symbol|number>} KEY + * @param {KEY} attributeName The attribute name that identifies the queried value. + * @return {delta.DeltaConfGetAttrs[KEY]|undefined} The queried attribute value. + * @public + */ + getAttribute (attributeName) { + return /** @type {any} */ (typeMapGet(this, attributeName)) + } + + /** + * Returns whether an attribute exists + * + * @param {string} attributeName The attribute name to check for existence. + * @return {boolean} whether the attribute exists. + * + * @public + */ + hasAttribute (attributeName) { + return /** @type {any} */ (typeMapHas(this, attributeName)) + } + + /** + * Returns all attribute name/value pairs in a JSON Object. + * + * @param {Snapshot} [snapshot] + * @return {{ [Key in Extract,string>]?: delta.DeltaConfGetAttrs[Key]}} A JSON Object that describes the attributes. + * + * @public + */ + getAttributes (snapshot) { + return /** @type {any} */ (snapshot ? typeMapGetAllSnapshot(this, snapshot) : typeMapGetAll(this)) + } + + /** + * Inserts new content at an index. + * + * Important: This function expects an array of content. Not just a content + * object. The reason for this "weirdness" is that inserting several elements + * is very efficient when it is done as a single operation. + * + * @example + * // Insert character 'a' at position 0 + * yarray.insert(0, ['a']) + * // Insert numbers 1, 2 at position 1 + * yarray.insert(1, [1, 2]) + * + * @param {number} index The index to insert content at. + * @param {Array>|delta.DeltaConfGetText} content Array of content to append. + */ + insert (index, content) { + this.applyDelta(delta.create().retain(index).insert(/** @type {any} */ (content))) + } + + /** + * Appends content to this YArray. + * + * @param {Array>|delta.DeltaConfGetText} content Array of content to append. + * + * @todo Use the following implementation in all types. + */ + push (content) { + this.insert(this.length, content) + } + + /** + * Prepends content to this YArray. + * + * @param {delta.DeltaConfGetText} content Array of content to prepend. + */ + unshift (content) { + this.insert(0, content) + } + + /** + * Deletes elements starting from an index. + * + * @param {number} index Index at which to start deleting elements + * @param {number} length The number of elements to remove. Defaults to 1. + */ + delete (index, length = 1) { + this.applyDelta(delta.create().retain(index).delete(length)) + } + + /** + * Returns the i-th element from a YArray. + * + * @param {number} index The index of the element to return from the YArray + * @return {delta.DeltaConfGetChildren} + */ + get (index) { + return typeListGet(this, index) + } + + /** + * Returns a portion of this YXmlFragment into a JavaScript Array selected + * from start to end (end not included). + * + * @param {number} [start] + * @param {number} [end] + * @return {Array>} + */ + slice (start = 0, end = this.length) { + return typeListSlice(this, start, end) + } + + + /** + * Transforms this YArray to a JavaScript Array. + * + * @return {Array>} + */ + toArray () { + return typeListToArray(this) + } + + /** + * Transforms this Shared Type to a JSON object. + */ + toJSON () { + return this.getContent().toJSON() + } + + /** + * Returns an Array with the result of calling a provided function on every + * child-element. + * + * @template M + * @param {(child:delta.DeltaConfGetChildren,index:number,ytype:this)=>M} f Function that produces an element of the new Array + * @return {Array} A new array with each element being the result of the + * callback function + */ + map (f) { + return typeListMap(this, /** @type {any} */ (f)) + } + + /** + * Executes a provided function once on every element of this YArray. + * + * @template M + * @param {(child:delta.DeltaConfGetChildren,index:number,ytype:this)=>M} f Function that produces an element of the new Array + */ + forEach (f) { + typeListForEach(this, f) + } + + /** + * Executes a provided function on once on every key-value pair. + * + * @param {(val:delta.DeltaConfGetAttrs[any],key:keyof delta.DeltaConfGetAttrs,ytype:this)=>any} f + */ + forEachAttr (f) { + this._map.forEach((item, key) => { + if (!item.deleted) { + f(item.content.getContent()[item.length - 1], /** @type {any} */ (key), this) + } + }) + } + + + + /** + * @return {IterableIterator>} + */ + [Symbol.iterator] () { + return typeListCreateIterator(this) + } + + /** + * Returns the keys for each element in the YMap Type. + * + * @return {IterableIterator>} + */ + attrKeys () { + return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[0]) + } + + /** + * Returns the values for each element in the YMap Type. + * + * @return {IterableIterator[any]>} + */ + attrValues () { + return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1]) + } + + /** + * Returns an Iterator of [key, value] pairs + * + * @return {IterableIterator<{ [K in keyof delta.DeltaConfGetAttrs]: [K,delta.DeltaConfGetAttrs] }[any]>} + */ + attrEntries () { + return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => /** @type {any} */ ([v[0], v[1].content.getContent()[v[1].length - 1]])) + } + + /** + * Returns the number of stored attributes (count of key/value pairs) + * + * @return {number} + */ + attrSize () { + return [...createMapIterator(this)].length + } + + /** + * @param {this} other + */ + [traits.EqualityTraitSymbol] (other) { + return this.getContent().equals(other.getContent()) + } + + /** + * @todo this doesn't need to live in a method. + * + * Transform the properties of this type to binary and write it to an + * BinaryEncoder. + * + * This is called when this Item is sent to a remote peer. + * + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to. + */ + _write (encoder) { + encoder.writeTypeRef(this._legacyTypeRef) + switch (this._legacyTypeRef) { + case contentType.YXmlElementRefID: + case contentType.YXmlHookRefID: { + encoder.writeKey(this.name) + break + } + } + } +} + +/** + * @param {import('./utils/UpdateDecoder.js').UpdateDecoderV1 | import('./utils/UpdateDecoder.js').UpdateDecoderV2} decoder + * @return {YType} + * + * @private + * @function + */ +export const readYType = decoder => { + const typeRef = decoder.readTypeRef() + const ytype = new YType(typeRef === contentType.YXmlElementRefID || typeRef === contentType.YXmlHookRefID ? decoder.readKey() : null) + ytype._legacyTypeRef = typeRef + return ytype +} + +/** + * @param {any} a + * @param {any} b + * @return {boolean} + */ +export const equalAttrs = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b)) + +/** + * @template {delta.DeltaConf} DConf + * @typedef {delta.DeltaConfOverwrite]: DeltaToYType[K]> }, + * children: DeltaToYType> + * }> + * } DeltaConfDeltaToYType + */ + +/** + * @template {any} Data + * @typedef {Exclude | (Extract extends delta.Delta ? (unknown extends DConf ? YType : never) : never)} DeltaToYType + */ + +/** + * @param {YType} type + * @param {number} start + * @param {number} end + * @return {Array} + * + * @private + * @function + */ +export const typeListSlice = (type, start, end) => { + type.doc ?? warnPrematureAccess() + if (start < 0) { + start = type._length + start + } + if (end < 0) { + end = type._length + end + } + let len = end - start + const cs = [] + let n = type._start + while (n !== null && len > 0) { + if (n.countable && !n.deleted) { + const c = n.content.getContent() + if (c.length <= start) { + start -= c.length + } else { + for (let i = start; i < c.length && len > 0; i++) { + cs.push(c[i]) + len-- + } + start = 0 + } + } + n = n.right + } + return cs +} + +/** + * @param {YType} type + * @return {Array} + * + * @private + * @function + */ +export const typeListToArray = type => { + type.doc ?? warnPrematureAccess() + const cs = [] + let n = type._start + while (n !== null) { + if (n.countable && !n.deleted) { + const c = n.content.getContent() + for (let i = 0; i < c.length; i++) { + cs.push(c[i]) + } + } + n = n.right + } + return cs +} + +/** + * @param {YType} type + * @param {Snapshot} snapshot + * @return {Array} + * + * @private + * @function + */ +export const typeListToArraySnapshot = (type, snapshot) => { + const cs = [] + let n = type._start + while (n !== null) { + if (n.countable && isVisible(n, snapshot)) { + const c = n.content.getContent() + for (let i = 0; i < c.length; i++) { + cs.push(c[i]) + } + } + n = n.right + } + return cs +} + +/** + * Executes a provided function on once on every element of this YArray. + * + * @param {YType} type + * @param {function(any,number,any):void} f A function to execute on every element of this YArray. + * + * @private + * @function + */ +export const typeListForEach = (type, f) => { + let index = 0 + let n = type._start + type.doc ?? warnPrematureAccess() + while (n !== null) { + if (n.countable && !n.deleted) { + const c = n.content.getContent() + for (let i = 0; i < c.length; i++) { + f(c[i], index++, type) + } + } + n = n.right + } +} + +/** + * @template C,R + * @param {YType} type + * @param {function(C,number,YType):R} f + * @return {Array} + * + * @private + * @function + */ +export const typeListMap = (type, f) => { + /** + * @type {Array} + */ + const result = [] + typeListForEach(type, (c, i) => { + result.push(f(c, i, type)) + }) + return result +} + +/** + * @param {YType} type + * @return {IterableIterator} + * + * @private + * @function + */ +export const typeListCreateIterator = type => { + let n = type._start + /** + * @type {Array|null} + */ + let currentContent = null + let currentContentIndex = 0 + return { + [Symbol.iterator] () { + return this + }, + next: () => { + // find some content + if (currentContent === null) { + while (n !== null && n.deleted) { + n = n.right + } + // check if we reached the end, no need to check currentContent, because it does not exist + if (n === null) { + return { + done: true, + value: undefined + } + } + // we found n, so we can set currentContent + currentContent = n.content.getContent() + currentContentIndex = 0 + n = n.right // we used the content of n, now iterate to next + } + const value = currentContent[currentContentIndex++] + // check if we need to empty currentContent + if (currentContent.length <= currentContentIndex) { + currentContent = null + } + return { + done: false, + value + } + } + } +} + +/** + * @todo remove / inline this + * + * @param {YType} type + * @param {number} index + * @return {any} + * + * @private + * @function + */ +export const typeListGet = (type, index) => { + type.doc ?? warnPrematureAccess() + const marker = findMarker(type, index) + let n = type._start + if (marker !== null) { + n = marker.p + index -= marker.index + } + for (; n !== null; n = n.right) { + if (!n.deleted && n.countable) { + if (index < n.length) { + return n.content.getContent()[index] + } + index -= n.length + } + } +} + +/** + * @param {Transaction} transaction + * @param {YType} parent + * @param {Item?} referenceItem + * @param {Array} content + * + * @private + * @function + */ +export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => { + let left = referenceItem + const doc = transaction.doc + const ownClientId = doc.clientID + const store = doc.store + const right = referenceItem === null ? parent._start : referenceItem.right + /** + * @type {Array|number|null>} + */ + let jsonContent = [] + const packJsonContent = () => { + if (jsonContent.length > 0) { + left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent)) + left.integrate(transaction, 0) + jsonContent = [] + } + } + content.forEach(c => { + if (c === null) { + jsonContent.push(c) + } else { + switch (c.constructor) { + case Number: + case Object: + case undefined: + case Boolean: + case Array: + case String: + case BigInt: + case Date: + jsonContent.push(c) + break + default: + packJsonContent() + switch (c.constructor) { + case Uint8Array: + case ArrayBuffer: + left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c)))) + left.integrate(transaction, 0) + break + case Doc: + left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentDoc(/** @type {Doc} */ (c))) + left.integrate(transaction, 0) + break + default: + if (c instanceof YType) { + left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(/** @type {any} */ (c))) + left.integrate(transaction, 0) + } else { + throw new Error('Unexpected content type in insert operation') + } + } + } + } + }) + packJsonContent() +} + +const lengthExceeded = () => error.create('Length exceeded!') + +/** + * @param {Transaction} transaction + * @param {YType} parent + * @param {number} index + * @param {Array|Array|number|null|string|Uint8Array>} content + * + * @private + * @function + */ +export const typeListInsertGenerics = (transaction, parent, index, content) => { + if (index > parent._length) { + throw lengthExceeded() + } + if (index === 0) { + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, index, content.length) + } + return typeListInsertGenericsAfter(transaction, parent, null, content) + } + const startIndex = index + const marker = findMarker(parent, index) + let n = parent._start + if (marker !== null) { + n = marker.p + index -= marker.index + // we need to iterate one to the left so that the algorithm works + if (index === 0) { + // @todo refactor this as it actually doesn't consider formats + n = n.prev // important! get the left undeleted item so that we can actually decrease index + index += (n && n.countable && !n.deleted) ? n.length : 0 + } + } + for (; n !== null; n = n.right) { + if (!n.deleted && n.countable) { + if (index <= n.length) { + if (index < n.length) { + // insert in-between + getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index)) + } + break + } + index -= n.length + } + } + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, startIndex, content.length) + } + return typeListInsertGenericsAfter(transaction, parent, n, content) +} + +/** + * Pushing content is special as we generally want to push after the last item. So we don't have to update + * the search marker. + * + * @param {Transaction} transaction + * @param {YType} parent + * @param {Array|Array|number|null|string|Uint8Array>} content + * + * @private + * @function + */ +export const typeListPushGenerics = (transaction, parent, content) => { + // Use the marker with the highest index and iterate to the right. + const marker = (parent._searchMarker || []).reduce((maxMarker, currMarker) => currMarker.index > maxMarker.index ? currMarker : maxMarker, { index: 0, p: parent._start }) + let n = marker.p + if (n) { + while (n.right) { + n = n.right + } + } + return typeListInsertGenericsAfter(transaction, parent, n, content) +} + +/** + * @param {Transaction} transaction + * @param {YType} parent + * @param {number} index + * @param {number} length + * + * @private + * @function + */ +export const typeListDelete = (transaction, parent, index, length) => { + if (length === 0) { return } + const startIndex = index + const startLength = length + const marker = findMarker(parent, index) + let n = parent._start + if (marker !== null) { + n = marker.p + index -= marker.index + } + // compute the first item to be deleted + for (; n !== null && index > 0; n = n.right) { + if (!n.deleted && n.countable) { + if (index < n.length) { + getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index)) + } + index -= n.length + } + } + // delete all items until done + while (length > 0 && n !== null) { + if (!n.deleted) { + if (length < n.length) { + getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length)) + } + n.delete(transaction) + length -= n.length + } + n = n.right + } + if (length > 0) { + throw lengthExceeded() + } + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, startIndex, -startLength + length /* in case we remove the above exception */) + } +} + +/** + * @todo inline this code + * + * @param {Transaction} transaction + * @param {YType} parent + * @param {string} key + * + * @private + * @function + */ +export const typeMapDelete = (transaction, parent, key) => { + const c = parent._map.get(key) + if (c !== undefined) { + c.delete(transaction) + } +} + +/** + * @param {Transaction} transaction + * @param {YType} parent + * @param {string} key + * @param {YValue} value + * + * @private + * @function + */ +export const typeMapSet = (transaction, parent, key, value) => { + const left = parent._map.get(key) || null + const doc = transaction.doc + const ownClientId = doc.clientID + let content + if (value == null) { + content = new ContentAny([value]) + } else { + switch (value.constructor) { + case Number: + case Object: + case Boolean: + case Array: + case String: + case Date: + case BigInt: + content = new ContentAny([value]) + break + case Uint8Array: + content = new ContentBinary(/** @type {Uint8Array} */ (value)) + break + case Doc: + content = new ContentDoc(/** @type {Doc} */ (value)) + break + default: + if (value instanceof YType) { + content = new ContentType(/** @type {any} */ (value)) + } else { + throw new Error('Unexpected content type') + } + } + } + new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0) +} + +/** + * @param {YType} parent + * @param {string} key + * @return {Object|number|null|Array|string|Uint8Array|YType|undefined} + * + * @private + * @function + */ +export const typeMapGet = (parent, key) => { + parent.doc ?? warnPrematureAccess() + const val = parent._map.get(key) + return val !== undefined && !val.deleted ? val.content.getContent()[val.length - 1] : undefined +} + +/** + * @param {YType} parent + * @return {Object|number|null|Array|string|Uint8Array|YType|undefined>} + * + * @private + * @function + */ +export const typeMapGetAll = (parent) => { + /** + * @type {Object} + */ + const res = {} + parent.doc ?? warnPrematureAccess() + parent._map.forEach((value, key) => { + if (!value.deleted) { + res[key] = value.content.getContent()[value.length - 1] + } + }) + return res +} + +/** + * @todo move this to getContent/getDelta + * + * Render the difference to another ydoc (which can be empty) and highlight the differences with + * attributions. + * + * Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the + * attribution `{ isDeleted: true, .. }`. + * + * @template {delta.DeltaBuilderAny} TypeDelta + * @param {TypeDelta} d + * @param {YType} parent + * @param {Set?} attrsToRender + * @param {import('./internals.js').AbstractAttributionManager} am + * @param {boolean} deep + * @param {Set|Map|null} [modified] - set of types that should be rendered as modified children + * @param {import('./utils/IdSet.js').IdSet?} [deletedItems] + * @param {import('./utils/IdSet.js').IdSet?} [itemsToRender] + * @param {any} [opts] + * @param {any} [optsAll] + * + * @private + * @function + */ +export const typeMapGetDelta = (d, parent, attrsToRender, am, deep, modified, deletedItems, itemsToRender, opts, optsAll) => { + // @todo support modified ops! + /** + * @param {Item} item + * @param {string} key + */ + const renderAttrs = (item, key) => { + /** + * @type {Array>} + */ + const cs = [] + am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1) + const { deleted, attrs, content } = cs[cs.length - 1] + const attribution = createAttributionFromAttributionItems(attrs, deleted) + let c = array.last(content.getContent()) + if (deleted) { + if (itemsToRender == null || itemsToRender.hasId(item.lastId)) { + d.deleteAttr(key, attribution, c) + } + } else if (deep && c instanceof YType && modified?.has(c)) { + d.modifyAttr(key, c.getContent(am, opts)) + } else { + // find prev content + let prevContentItem = item + // this algorithm is problematic. should check all previous content using am.readcontent + for (; prevContentItem.left !== null && deletedItems?.hasId(prevContentItem.left.lastId); prevContentItem = prevContentItem.left) { + // nop + } + const prevValue = (prevContentItem !== item && itemsToRender?.hasId(prevContentItem.lastId)) ? array.last(prevContentItem.content.getContent()) : undefined + if (deep && c instanceof YType) { + c = /** @type {any} */(c).getContent(am, optsAll) + } + d.setAttr(key, c, attribution, prevValue) + } + } + if (attrsToRender == null) { + parent._map.forEach(renderAttrs) + } else { + attrsToRender.forEach(key => key != null && renderAttrs(/** @type {Item} */ (parent._map.get(key)), key)) + } +} + +/** + * @param {YType} parent + * @param {string} key + * @return {boolean} + * + * @private + * @function + */ +export const typeMapHas = (parent, key) => { + parent.doc ?? warnPrematureAccess() + const val = parent._map.get(key) + return val !== undefined && !val.deleted +} + +/** + * @param {YType} parent + * @param {string} key + * @param {Snapshot} snapshot + * @return {Object|number|null|Array|string|Uint8Array|YType|undefined} + * + * @private + * @function + */ +export const typeMapGetSnapshot = (parent, key, snapshot) => { + let v = parent._map.get(key) || null + while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) { + v = v.left + } + return v !== null && isVisible(v, snapshot) ? v.content.getContent()[v.length - 1] : undefined +} + +/** + * @param {YType} parent + * @param {Snapshot} snapshot + * @return {Object|number|null|Array|string|Uint8Array|YType|undefined>} + * + * @private + * @function + */ +export const typeMapGetAllSnapshot = (parent, snapshot) => { + /** + * @type {Object} + */ + const res = {} + parent._map.forEach((value, key) => { + /** + * @type {Item|null} + */ + let v = value + while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) { + v = v.left + } + if (v !== null && isVisible(v, snapshot)) { + res[key] = v.content.getContent()[v.length - 1] + } + }) + return res +} + +/** + * @param {YType & { _map: Map }} type + * @return {IterableIterator>} + * + * @private + * @function + */ +export const createMapIterator = type => { + type.doc ?? warnPrematureAccess() + return iterator.iteratorFilter(type._map.entries(), /** @param {any} entry */ entry => !entry[1].deleted) +}