import * as binary from 'lib0/binary' import * as array from 'lib0/array' import * as delta from 'lib0/delta' import * as error from 'lib0/error' import * as iterator from 'lib0/iterator' import * as log from 'lib0/logging' import * as map from 'lib0/map' import * as set from 'lib0/set' import * as math from 'lib0/math' import * as object from 'lib0/object' import * as s from 'lib0/schema' import * as traits from 'lib0/traits' import { ObservableV2 } from 'lib0/observable' import { Item, ContentAny, ContentBinary, ContentDeleted, ContentEmbed, ContentFormat, ContentJSON, ContentString, ContentType, YXmlFragmentRefID, YXmlElementRefID, YXmlHookRefID, ContentDoc, createContentDocFromDoc } from './structs/Item.js' import { AttributedContent, rendererContentLength } from './utils/renderer-helpers.js' import { removeEventHandlerListener, callEventHandlerListeners, addEventHandlerListener, createEventHandler } from './utils/EventHandler.js' import { createID } from './utils/ID.js' import { createIdSet, iterateStructsByIdSetWithoutSplits } from './utils/ids.js' import { getItemCleanStart, cleanupFormattingGap } from './utils/transaction-helpers.js' import { transact } from './utils/Transaction.js' import { YEvent } from './utils/YEvent.js' import { $doc } from './utils/schemas.js' /** * @typedef {Object|Array|number|null|string|Uint8Array|BigInt|YNode} 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 /** * @todo SHOULD NOT RETURN AN OBJECT! * @param {Array>?} attrs * @param {boolean} deleted - whether the attributed item is deleted * @return {Attribution|undefined} `undefined` when there is no attribution — under lib0's tri-state * that means "skip / inherit the builder's attribution context" (NOT `null`, which would clear it). */ export const createAttributionFromAttributionItems = (attrs, deleted) => { if (attrs == null) { return undefined } /** * @type {Attribution} */ const attribution = {} if (deleted) { attribution.delete = [] } else { attribution.insert = [] } attrs.forEach(attr => { switch (attr.name) { // eslint-disable-next-line no-fallthrough case 'insert': case 'delete': { // needs to be non-ambiguous: don't add existing attr if it doesn't match the actual status attribution[attr.name]?.push(attr.val) break } default: { if (attr.name[0] !== '_') { /** @type {any} */ (attribution)[attr.name] = attr.val } } } }) return attribution } /** * 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} currentFormats * @param {AbstractRenderer?} renderer */ constructor (left, right, index, currentFormats, renderer) { this.left = left this.right = right this.index = index this.currentFormats = currentFormats this.renderer = renderer } /** * 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) { updateCurrentFormats(this.currentFormats, /** @type {ContentFormat} */ (this.right.content)) } break default: this.index += rendererContentLength(this.renderer, this.right) break } this.left = this.right this.right = this.right.right } /** * @param {Transaction} transaction * @param {YNode} parent * @param {number} length * @param {Object} formats * * @function */ formatText (transaction, parent, length, formats) { minimizeFormatChanges(this, formats) const negatedFormats = insertFormats(transaction, parent, this, formats) // iterate until first non-format or null is found // delete all formats with formats[format.key] != null // also check the formats after the first non-format as we do not want to insert redundant negated formats there // eslint-disable-next-line no-labels iterationLoop: while ( this.right !== null && (length > 0 || ( negatedFormats.size > 0 && ((this.right.deleted && rendererContentLength(this.renderer, 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 = formats[key] if (attr !== undefined) { if (equalFormats(attr, value)) { negatedFormats.delete(key) } else { if (length === 0) { // no need to further extend negatedFormats // eslint-disable-next-line no-labels break iterationLoop } negatedFormats.set(key, value) } this.right.delete(transaction) } else { this.currentFormats.set(key, value) } } break } default: { const item = this.right const rightLen = rendererContentLength(this.renderer, item) if (length < rightLen) { if (this.renderer !== null && this.renderer.hasItem(item)) { /** * @type {Array>} */ const contents = [] this.renderer.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 { // plain content: split directly at the offset getItemCleanStart(transaction, createID(item.id.client, item.id.clock + length)) length = 0 } } else { length -= rightLen } break } } this.forward() } if (length > 0) { throw new Error('Exceeded content range') } insertNegatedFormats(transaction, parent, this, negatedFormats) } } /** * Negate applied formats * * @param {Transaction} transaction * @param {YNode} parent * @param {ItemTextListPosition} currPos * @param {Map} negatedFormats * * @private * @function */ const insertNegatedFormats = (transaction, parent, currPos, negatedFormats) => { // check if we really need to remove formats while ( currPos.right !== null && ( (currPos.right.deleted && rendererContentLength(currPos.renderer, currPos.right) === 0) || ( currPos.right.content.constructor === ContentFormat && equalFormats(negatedFormats.get(/** @type {ContentFormat} */ (currPos.right.content).key), /** @type {ContentFormat} */ (currPos.right.content).value) ) ) ) { if (!currPos.right.deleted) { negatedFormats.delete(/** @type {ContentFormat} */ (currPos.right.content).key) } currPos.forward() } const doc = transaction.doc const ownClientId = doc.clientID negatedFormats.forEach((val, key) => { const left = currPos.left const right = currPos.right const nextFormat = new Item(createID(ownClientId, doc.store.getClock(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} currentFormats * @param {ContentFormat} format * * @private * @function */ const updateCurrentFormats = (currentFormats, format) => { const { key, value } = format if (value === null) { currentFormats.delete(key) } else { currentFormats.set(key, value) } } /** * @param {ItemTextListPosition} currPos * @param {Object} formats * * @private * @function */ const minimizeFormatChanges = (currPos, formats) => { // go right while formats[right.key] === right.value (or right is deleted) while (true) { if (currPos.right === null) { break } else if (currPos.right.deleted ? (rendererContentLength(currPos.renderer, currPos.right) === 0) : (!currPos.right.deleted && currPos.right.content.constructor === ContentFormat && equalFormats(formats[(/** @type {ContentFormat} */ (currPos.right.content)).key] ?? null, /** @type {ContentFormat} */ (currPos.right.content).value))) { // } else { break } currPos.forward() } } /** * @param {Transaction} transaction * @param {YNode} parent * @param {ItemTextListPosition} currPos * @param {Object} formats * @return {Map} * * @private * @function **/ const insertFormats = (transaction, parent, currPos, formats) => { const doc = transaction.doc const ownClientId = doc.clientID const negatedFormats = new Map() // insert format-start items for (const key in formats) { const val = formats[key] const currentVal = currPos.currentFormats.get(key) ?? null if (!equalFormats(currentVal, val)) { // save negated format (set null if currentVal undefined) negatedFormats.set(key, currentVal) const { left, right } = currPos currPos.right = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val)) currPos.right.integrate(transaction, 0) currPos.forward() } } return negatedFormats } /** * @param {Transaction} transaction * @param {YNode} parent * @param {ItemTextListPosition} currPos * @param {import('./structs/Item.js').AbstractContent} content * @param {Object} formats * * @private * @function **/ export const insertContent = (transaction, parent, currPos, content, formats) => { currPos.currentFormats.forEach((_val, key) => { if (formats[key] === undefined) { formats[key] = null } }) const doc = transaction.doc const ownClientId = doc.clientID minimizeFormatChanges(currPos, formats) const negatedFormats = insertFormats(transaction, parent, currPos, formats) let { left, right, index } = currPos if (parent._searchMarker) { updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength()) } right = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content) right.integrate(transaction, 0) currPos.right = right currPos.index = index currPos.forward() insertNegatedFormats(transaction, parent, currPos, negatedFormats) } /** * @param {Transaction} transaction * @param {YNode} parent * @param {ItemTextListPosition} currPos * @param {Array|string} insert * @param {Object} formats */ export const insertContentHelper = (transaction, parent, currPos, insert, formats) => { if (s.$string.check(insert)) { insertContent(transaction, parent, currPos, new ContentString(insert), formats) } else { insert = insert.map(ins => delta.$deltaAny.check(ins) ? YNode.from(ins) : ins) for (let i = 0; i < insert.length;) { const first = insert[i] if (first instanceof YNode) { insertContent(transaction, parent, currPos, new ContentType(first), formats) i++ } else if ($doc.check(first)) { insertContent(transaction, parent, currPos, createContentDocFromDoc(first), formats) i++ } else { // insert "any" content // compute slice len let j = i + 1 for (; j < insert.length && !(insert[j] instanceof YNode || $doc.check(insert[j])); j++) { /* nop */ } insertContent(transaction, parent, currPos, new ContentAny((i === 0 && j === insert.length) ? insert : insert.slice(i, j)), formats) i = j } } } } /** * @param {Transaction} transaction * @param {ItemTextListPosition} currPos * @param {number} length * @return {ItemTextListPosition} * * @private * @function */ export const deleteText = (transaction, currPos, length) => { const startLength = length const startFormats = map.copy(currPos.currentFormats) const start = currPos.right while (length > 0 && currPos.right !== null) { const item = currPos.right if (!item.deleted && item.countable) { if (length < item.length) { getItemCleanStart(transaction, createID(item.id.client, item.id.clock + length)) } length -= item.length item.delete(transaction) } else if (currPos.renderer !== null && currPos.renderer.hasItem(item)) { /** * @type {Array>} */ const contents = [] currPos.renderer.readContent(contents, item.id.client, item.id.clock, true, item.content, 0) let splitClock = -1 for (let i = 0; i < contents.length; i++) { const c = contents[i] if (c.content.isCountable() && c.attrs != null) { if (length === 0) { // the delete is exhausted but this item renders more content — split so the cursor // advances only past the consumed part (mirrors formatText's renderer branch); // otherwise every following op of the same delta targets too far right splitClock = c.clock break } // deleting already deleted content. store that information in a meta property, but do // nothing const pieceLen = c.content.getLength() const contentLen = math.min(pieceLen, length) map.setIfUndefined(transaction.meta, 'attributedDeletes', createIdSet).add(item.id.client, c.clock, contentLen) length -= contentLen if (contentLen < pieceLen) { splitClock = c.clock + contentLen break } } } if (splitClock >= 0) { getItemCleanStart(transaction, createID(item.id.client, splitClock)) } else { 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, startFormats, currPos.currentFormats) } const parent = /** @type {YNode} */ (/** @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 {YNode} 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 {YNode} t * @return {Array} */ export const getNodeChildren = 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 {YNode} type * @param {Transaction} transaction * @param {YEvent} event */ export const callTypeObservers = (type, transaction, event) => { const changedType = type const changedParentTypes = transaction.changedParentTypes // track the event unconditionally — also for deleted types: the walk carries changes inside a // deleted type (e.g. a suggestion-deleted tombstone that a custom renderer still renders) up to // the deep-event / 'delta' / cache consumers on live ancestors. while (true) { // @ts-ignore map.setIfUndefined(changedParentTypes, type, () => []).push(event) if (type._item === null) { break } type = /** @type {YNode} */ (type._item.parent) } // a deleted type's own observers stay silent (deleted content is invisible) — unless a // renderer is attached to it, which may still render the type (mirrors the fire-time rule for // `changedParentTypes` targets in `cleanupTransactions`). if (changedType._item === null || !changedType._item.deleted || changedType._renderer !== null) { callEventHandlerListeners(/** @type {any} */ (changedType._eH), event, transaction) } } /** * Abstract Yjs Type class. * * A `YNode` is a {@link https://github.com/dmonad/lib0 lib0} `RDT` ("replicated data type", see * `lib0/delta/rdt.js`): it emits a `'delta'` event whenever its state changes (carrying the change * and the origin of the transaction that caused it), accepts foreign * changes via {@link YNode#applyDelta}, exposes its delta {@link YNode#$delta schema}, and can be * torn down via {@link YNode#destroy}. This lets a `YNode` be `bind()`-ed to any other RDT (another * `YNode`, an in-memory delta, a DOM subtree, …). The legacy {@link YNode#observe `observe`} / * {@link YNode#observeDeep `observeDeep`} `YEvent` API continues to work alongside the `'delta'` * channel. * * @template {delta.DeltaConf} [DConf=any] * @extends {ObservableV2<{ delta: (delta: delta.Delta, origin: any) => void, destroy: (type: YNode) => void }>} */ export class YNode extends ObservableV2 { /** * @param {delta.DeltaConfGetName?} name */ constructor (name = null) { super() /** * @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 /** * Maintained deep-delta cache backing {@link YNode#delta}. `null` until `delta` is first * accessed; thereafter kept current on every event of this type (incrementally, by applying the * deep change) and re-diffed by {@link YNode#useRenderer}. Cleared by {@link YNode#clearCache}. * * INVARIANT (fingerprint-memo safety): consumers (e.g. y-sync) diff against this LIVE object * via {@link YNode#delta}, which memoizes `_fingerprint` on its delta/op nodes at every depth. * Every in-place patch — the per-transaction `apply` in `cleanupTransactions` and the * renderer-overlay `apply` in {@link typeApplyRendererChange} — MUST route through the lib0 * delta builder API (`apply`), which invalidates those memos. NEVER write delta/op fields * directly: a direct write leaves stale memos, corrupting every subsequent fingerprint read * and diff. Pinned by the `testRdtFingerprintMemo*CacheDrift` tests. * @type {delta.DeltaBuilderAny | null} */ this._delta = null this._legacyTypeRef = this.name == null ? YXmlFragmentRefID : YXmlElementRefID /** * @type {Array|null} */ this._searchMarker = [] /** * Whether this YText contains formats. * This flag is updated when a formatting item is integrated (see ContentFormat.integrate) */ this._hasFormatting = false /** * The active default renderer. Used by `toDelta`, `applyDelta`, and the events whenever no * explicit renderer is passed. `null` = no renderer: content renders as-is via the generic * fast path. Change it via {@link YNode#useRenderer}. * @type {AbstractRenderer?} */ this._renderer = null /** * Bound listener on the active renderer's `'change'` event — attribution corrections that * happen without a Y transaction on this doc (e.g. a suggestion is accepted and the * renderer's attribution overlay updates). `null` while no renderer is active. * Managed by {@link YNode#useRenderer}; see {@link typeApplyRendererChange}. * @type {((changes: IdSet, origin: any, local: boolean) => void) | null} */ this._rendererChangeHandler = null } /** * Schema of the deltas this type produces — part of the lib0 `RDT` interface. * * @type {s.Schema>} */ get $delta () { return /** @type {any} */ (delta.$deltaAny) } /** * The deep delta of this type (the full nested content tree, children rendered as their own * deltas). * * The returned value is the type's **live** maintained cache: it is materialized on first access * and then kept current on every event fired on this type (and re-diffed by * {@link YNode#useRenderer}), so a reference held across edits keeps updating in place. Clone it * (e.g. `type.delta.clone()`) if you need a stable snapshot, and call {@link YNode#clearCache} to * drop the cache. * * Consider the returned delta **done** — it must not be edited from the outside. It is * deliberately typed as a `Delta` (not a `DeltaBuilder`) so the mutating builder API is not * reachable; editing it anyway would corrupt the cache without changing the CRDT. The proper way * to change this type is {@link YNode#applyDelta}. * * @type {delta.Delta} */ get delta () { if (this._delta === null) { this._delta = this._renderDelta() } return /** @type {any} */ (this._delta) } /** * Render the full deep current state into a fresh `isFinal` builder (so subsequent `.apply`s of * deep changes update content in place). Uses this type's active renderer. * * @return {delta.DeltaBuilderAny} */ _renderDelta () { const state = /** @type {delta.DeltaBuilderAny} */ (delta.create(this.name)) state.isFinal = true state.apply(this.toDelta({ deep: true })) return state } /** * Discard the cached deep delta backing {@link YNode#delta}. * * After `delta` is first accessed, the cache is updated on every event fired on this type (and * re-diffed by {@link YNode#useRenderer}). Call this to drop it — e.g. to reclaim memory, or to * force an exact recomputation after editing while a non-base renderer is active (the incremental * updates can drift from a fresh deep render in that case). */ clearCache () { this._delta = null } /** * Change the default renderer used by this type. After calling `useRenderer(renderer)`, the * `toDelta`, `applyDelta`, and event methods all use `renderer` whenever no explicit renderer is * passed (an explicit `{ renderer }` argument still overrides it per call). * * If the deep-delta cache ({@link YNode#delta}) is being maintained, or a `'delta'` listener is * attached, the content is re-rendered with the new renderer and the difference is emitted on the * `'delta'` channel only (a renderer switch is not a CRDT change, so no `YEvent` is produced, and * the emitted origin is `null` as no transaction is involved). * * @param {AbstractRenderer?} renderer - `null` detaches: content renders as-is again * @return {this} */ useRenderer (renderer) { const prev = this._renderer if (renderer === prev) return this if (this._rendererChangeHandler !== null) { /** @type {AbstractRenderer} */ (prev).off('change', this._rendererChangeHandler) this._rendererChangeHandler = null } if (renderer !== null && this._rendererChangeHandler === null) { // attribution corrections (e.g. accepting a suggestion) reach the renderer without a Y // transaction on this doc — subscribe so the RDT surface (maintained cache + 'delta' // channel) stays current. Without a renderer there are no attributions, hence no // subscription. Not gated on `renderer !== prev`: after `destroy()` the handler is removed // while `_renderer` keeps pointing at the renderer, so re-activating with the same renderer // must re-subscribe. this._rendererChangeHandler = (changes, origin) => typeApplyRendererChange(this, changes, origin) renderer.on('change', this._rendererChangeHandler) } const hasDeltaListeners = (this._observers.get('delta')?.size ?? 0) > 0 if (this._delta !== null || hasDeltaListeners) { const oldState = this._delta ?? this._renderDelta() this._renderer = renderer const newState = this._renderDelta() if (this._delta !== null) this._delta = newState if (hasDeltaListeners) { const d = /** @type {any} */ (delta.diff(/** @type {any} */ (oldState), /** @type {any} */ (newState))) if (!d.isEmpty()) this.emit('delta', [d, null]) } } else { this._renderer = renderer } return this } /** * Tear down this type as an `RDT`: emit the `'destroy'` event, unregister all `'delta'` / * `'destroy'` listeners, and reset the RDT surface (active renderer detached, the * maintained {@link YNode#delta} cache dropped). The CRDT content and the * `observe`/`observeDeep` handlers are left untouched — this only releases the RDT/binding * observers. Without the reset, a still-materialized cache would keep being maintained through * the transaction path but no longer receive the renderer's attribution corrections — reading * `delta` after `destroy()` would silently drift. Re-activating is fully supported: * `useRenderer(renderer)` re-subscribes and `delta` re-materializes. */ destroy () { if (this._rendererChangeHandler !== null) { /** @type {AbstractRenderer} */ (this._renderer).off('change', this._rendererChangeHandler) this._rendererChangeHandler = null } this._renderer = null this._delta = null this.emit('destroy', [this]) super.destroy() } /** * @template {delta.DeltaConf} DC * @param {delta.Delta} d * @return {YNode} */ static from (d) { const yt = new YNode(d.name) yt.applyDelta(d) return yt } get length () { this.doc ?? warnPrematureAccess() return this._length } /** * Returns a fresh delta that can be used to change this YNode. * @type {delta.DeltaBuilder>} */ get change () { return /** @type {any} */ (delta.create()) } /** * @return {YNode?} */ get parent () { return /** @type {YNode?} */ (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 {YNode} */ _copy () { const ynode = new YNode(this.name) ynode._legacyTypeRef = this._legacyTypeRef return ynode } /** * 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) // Note: the RDT `'delta'` channel (and the deep-delta cache) is driven in the transaction // cleanup's `changedParentTypes` loop (see Transaction.js) so it bubbles to ancestors like // `observeDeep`, not here where only the directly-changed type is visible. 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 {Object} [opts] * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed * @param {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 {IdSet?} [opts.insertedItems] - ids inserted by the change being rendered; content that is both in `insertedItems` and deleted renders as a *fresh* insert even under `retainDeletes` (it was never part of the consuming state) * @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 */ toDelta (opts = {}) { const { renderer = this._renderer, itemsToRender = null, retainInserts = false, retainDeletes = false, insertedItems = null, deep = false } = opts const { modified = (deep && itemsToRender) ? computeModifiedFromItems(/** @type {Doc} */ (this.doc).store, itemsToRender) : null } = opts const renderAttrs = modified?.get(this) || null const renderChildren = modified == null || !modified.has(this) || /** @type {Set} */ (modified.get(this)).has(null) /** * @type {delta.DeltaBuilderAny} */ const d = /** @type {any} */ (delta.create(this.name)) const optsAll = object.assign({}, opts, { renderer, modified }) // opts has been re-computed - do not use opts after this point! nodeMapGetDelta(d, /** @type {any} */ (this), renderAttrs, renderer, deep, modified, itemsToRender, optsAll, optsAll) if (renderChildren) { /** * @type {delta.Formats} */ let currentFormats = {} // saves all current formats for insert let usingCurrentFormats = false /** * @type {delta.Formats} */ let changedFormats = {} // saves changed formats for retain let usingChangedFormats = false /** * Logic for format attribution * Everything that comes after a format 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 format and hence restores the previously known formatting * that is not attributed. * @type {delta.Formats} */ const previousUnattributedFormats = {} // contains previously known unattributed formatting /** * @type {delta.Formats} */ const previousFormats = {} // The value before changes /** * Per-key provenance of `currentFormats`: `true` iff the marker currently governing the key * opened a *pending attributed* range (the attributor-push branch below). Consulted only at * deleted markers in change renders (`isDeletedFormatClear`) to distinguish a re-exposed * committed enclosing value (must clear the stale attribution from the cache) from a * re-exposed pending suggested value (must preserve it — the re-bolding case). * @type {{ [key: string]: boolean }} */ const currentFormatsAttributed = {} /** * Attribution argument for a change-render retain whose content lost its *own* attribution. * `null` clears everything — correct in an attribution-free context — but inside an ambient * format attribution (installed via `useAttribution` by a surrounding format marker) the * fresh render still stamps that ambient format on this content. An object argument merges * over the ambient context (lib0 `combineInstr`), so emit per-key removals instead: they * clear only the op's own insert/delete attribution while re-asserting the ambient format. */ const clearedOwnAttribution = () => d.usedAttribution == null ? null : /** @type {any} */ ({ insert: null, insertAt: null, delete: null, deleteAt: null }) /** * Format keys touched by a *rendered* format marker during an attributing change render. A * merely retained attributed marker normally must not emit ops — but when a rendered * same-key marker preceded it in this walk, the change is what made the retained boundary * reset-to-previous (e.g. a base-doc format arriving under a same-key format suggestion), * and the spans it governs must drop their now-stale attribution from the cache. Per-key * tracking is required (a plain "any format rendered" flag would over-clear other keys), * but the set is only ever consulted at format markers — never per character — and stays * `null` on all hot paths: full renders, base-renderer renders, and change renders without * format markers never allocate it. * @type {Set?} */ let renderedFormatKeys = null /** * Renderer output for a single item — a renderer may split an item into multiple * attributed pieces. * @type {Array>} */ const cs = [] /** * Process one piece of (possibly attributed) content — the shared op-emission and format * state machine. The renderer path feeds it every piece produced by `readContent`; the * generic path feeds it `ContentFormat` markers only (the state machine must see every * marker, attributed or not — a plain marker can e.g. close a formerly attributed range) * and emits ops for plain content directly. * @param {AttributedContent} c */ const processContent = c => { // 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 formats. const renderDelete = c.render && c.deleted // existing content that should be retained, only adding changed formats const retainContent = !c.render && (!c.deleted || c.attrs != null) const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : undefined switch (c.content.constructor) { case ContentDeleted: { // fresh (mode 3) content renders as nothing here: the consuming state never saw it, // and gc'd content cannot render as an insert — a delete op would misapply if (renderDelete && !c.fresh) d.delete(c.content.getLength()) break } case ContentString: if (renderContent) { if (c.deleted ? (retainDeletes && !c.fresh) : retainInserts) { // a retain expresses the format *diff* against existing (cached) content, so use // `changedFormats`: a format removed this change (e.g. its marker was deleted) // is present there as a `null` clear, whereas `currentFormats` (absolute) can // only re-assert present formats and would silently keep a stale one. // (`c.fresh` content is exempt from `retainDeletes`: it was inserted *and* // deleted by this very change, so the consuming state holds nothing to retain — // it renders as an insert below, carrying its attribution.) d.useFormats(changedFormats) usingChangedFormats = true // change render: a retained item with no attribution means its attribution was // removed → emit a clear rather than `{}` (skip). Present attribution merges. d.retain(/** @type {ContentString} */ (c.content).str.length, undefined, attribution ?? clearedOwnAttribution()) } else { d.useFormats(currentFormats) usingCurrentFormats = true d.insert(/** @type {ContentString} */ (c.content).str, undefined, attribution) } } else if (renderDelete) { d.delete(c.content.getLength()) } else if (retainContent) { d.useFormats(changedFormats) usingChangedFormats = true d.retain(c.content.getLength()) } break case ContentEmbed: case ContentAny: case ContentJSON: case ContentType: case ContentBinary: case ContentDoc: if (renderContent) { if (c.deleted ? (retainDeletes && !c.fresh) : retainInserts) { // a retain expresses the format *diff* → use `changedFormats` (see ContentString) d.useFormats(changedFormats) usingChangedFormats = true if (c.deleted && c.content.constructor === ContentType) { // @todo use current transaction instead d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), undefined, attribution ?? null) } else if (c.content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (c.content).type)) { // a de/re-attributed node the renderer still claims (e.g. a partial accept): // merge/clear its own attribution and descend so the nested walk heals exactly // the child ids in `itemsToRender` — other children keep their attribution d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), undefined, attribution ?? clearedOwnAttribution()) } else { d.retain(c.content.getLength(), undefined, attribution ?? clearedOwnAttribution()) } } else if (deep && c.content.constructor === ContentType) { d.useFormats(currentFormats) usingCurrentFormats = true d.insert([/** @type {any} */(c.content).type.toDelta(optsAll)], undefined, attribution) } else { d.useFormats(currentFormats) usingCurrentFormats = true d.insert(c.content.getContent(), undefined, 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.toDelta(optsAll)) } else { d.useFormats(changedFormats) usingChangedFormats = true d.retain(1) } } break case ContentFormat: { const { key, value } = /** @type {ContentFormat} */ (c.content) const currFormatVal = currentFormats[key] ?? null if (attribution != null && (c.deleted || !object.hasProperty(previousUnattributedFormats, key))) { previousUnattributedFormats[key] = c.deleted ? value : currFormatVal } // @todo write a function "updateCurrentFormats" and "updateChangedFormats" // # Update Formats if (renderContent || renderDelete) { // create fresh references if (usingCurrentFormats) { currentFormats = object.assign({}, currentFormats) usingCurrentFormats = false } if (usingChangedFormats) { usingChangedFormats = false changedFormats = object.assign({}, changedFormats) } } if (renderContent || renderDelete) { if (itemsToRender !== null && renderer !== null) { // only attributing change renders consult this (see the reset branch below) if (renderedFormatKeys === null) renderedFormatKeys = new Set() renderedFormatKeys.add(key) } // `previousFormats` tracks the value governing the current walk position in the // *consuming cache state*, so the retain diff below stays a plain cache → new-state // comparison. Contributors: markers deleted by this change in event renders (their // value governed the cache until now; heal-rendered deletes are PRE-deleted markers // whose removal the cache already rendered — they must not contribute), alive // markers re-rendered by heal renders (`retainInserts` — their value was in the // cache), and retained alive markers (the branch below). A gated update here is not // enough: a marker deleted by the change whose value coincides with the walk's // current value would silently drop out of the cache-reference tracking, and a later // marker would be misread as a restore-to-previous, swallowing a needed format diff. if (c.deleted) { if (itemsToRender !== null && !retainInserts) previousFormats[key] = value } else { // !c.deleted if (retainInserts) previousFormats[key] = value if (value == null) { delete currentFormats[key] } else { currentFormats[key] = value } currentFormatsAttributed[key] = false } // the retain diff for following spans is exactly cache (previousFormats) → new // state (currentFormats), recomputed per key at every rendered marker if (equalFormats(currentFormats[key] ?? null, previousFormats[key] ?? null)) { delete changedFormats[key] } else { changedFormats[key] = currentFormats[key] ?? null } } else if (retainContent && !c.deleted) { // fresh reference to currentFormats only if (usingCurrentFormats) { currentFormats = object.assign({}, currentFormats) usingCurrentFormats = false } if (usingChangedFormats && changedFormats[key] !== undefined) { usingChangedFormats = false changedFormats = object.assign({}, changedFormats) } if (value == null) { delete currentFormats[key] } else { currentFormats[key] = value } currentFormatsAttributed[key] = false delete changedFormats[key] previousFormats[key] = value } // # Update Attributions // A format marker deleted in a change render under an *attributing* renderer nets to no // attribution (its insert+delete suggestion cancels → `attribution == null`), yet it ends // the attributed range it opened: the following retained content must drop the stale // `{ format: { [key]: [] } }` the marker's insertion wrote to the cache. Emit an explicit // `null` leaf for the key (a context-wide `useAttribution(null)` cannot carry a per-key // clear). Conditions: only an attributing render (`renderer !== null`; without a // renderer there are no attributions to clear), and only when the deletion reverts to // a value that is not itself governed by a pending suggestion: either no value at all // (`currFormatVal == null`) or a *committed* enclosing value (provenance tracked in // `currentFormatsAttributed` — e.g. an accept/reject deleting a marker re-exposes the // enclosing committed `bold:true`, and the span's stale suggested-format attribution // must be cleared). If it reverts to a still-pending *attributed* value (deleting a // `bold:null` marker re-exposes an enclosing attributed `bold:true`, as when // re-bolding), the attribution is preserved. Suppressed entirely while an attributed // same-key range is open (`previousUnattributedFormats` has the key): there the fresh // render skips this marker — its governance belongs to the still-open attributed // range, whose ambient context re-stamps the correct attribution on the following // retains — mirroring the identical guard on `isAcceptedFormatClear`. const isDeletedFormatClear = attribution == null && renderer !== null && renderDelete && c.deleted && itemsToRender != null && (currFormatVal == null || currentFormatsAttributed[key] !== true) && !equalFormats(value, currFormatVal) && !object.hasProperty(previousUnattributedFormats, key) // The alive-marker analogue of `isDeletedFormatClear`: a format marker whose // *attribution* was removed while the marker survives — its suggestion was accepted // (the marker's id arrives via the renderer's `'change'` event). The original change // render stamped `{ format: { [key]: [] } }` on the spans this marker governs, so the // heal render must emit a per-key `null` leaf over those spans (a blunt // `attribution: null` would also wipe unrelated attributions such as a co-located // `{ delete: [] }`). Gated on `retainInserts` — the diff-against-existing-content // mode used by attribution-heal renders: in a plain event render an unattributed // alive marker is just new unattributed content and must not emit spurious clears. // When the key is tracked as an open attributed range (`previousUnattributedFormats`) // the accepted marker instead defers to the regular branch chain below, which mirrors // the fresh render exactly: mid-range it *keeps* the ambient attribution (relative to // the base doc the span's format is still a suggested change), and at the range end // (`sameAsPreviousAttributions`) it closes the attribution context. const isAcceptedFormatClear = attribution == null && renderer !== null && renderContent && !c.deleted && itemsToRender != null && retainInserts && !object.hasProperty(previousUnattributedFormats, key) if (attribution != null || isDeletedFormatClear || isAcceptedFormatClear || object.hasProperty(previousUnattributedFormats, key)) { /** * @type {Attribution} */ const formattingAttribution = object.assign({}, d.usedAttribution) const changedAttributedFormats = /** @type {{ [key: string]: Array|null }} */ (formattingAttribution.format = object.assign({}, formattingAttribution.format ?? {})) const sameAsPreviousAttributions = equalFormats(previousUnattributedFormats[key], currentFormats[key] ?? null) // An *unattributed* marker rendered by this change (e.g. a fresh base-doc marker) // that closes an open attributed same-key range by restoring the previous // unattributed value: the spans it governs must drop their stale attribution from // the cache, exactly like the attributed range-end below. Mirrors the fresh render // precisely: there, the reset only closes the ambient attribution when removing the // key leaves the format context empty (the `useAttribution(null)` case below) — // while another attributed key holds the context open, the removal happens on a // copy that is never installed, so the key survives on the following spans and the // change render must NOT clear it. const isRangeEndClear = attribution == null && sameAsPreviousAttributions && renderContent && !c.deleted && itemsToRender != null && renderer !== null && object.hasProperty(previousUnattributedFormats, key) && object.every(changedAttributedFormats, (_v, k) => k === key) if (isDeletedFormatClear || (isAcceptedFormatClear && changedAttributedFormats[key] == null)) { // uniformly emit the per-key clear — also for a marker that *ends* a formerly // attributed range: the attributed render emits a trailing per-key `null` past // the closing marker as well (see the `attribution != null` case below), so the // heal render mirrors that exact span; where the cache holds no stale // attribution the clear merges as a no-op. changedAttributedFormats[key] = null delete previousUnattributedFormats[key] } else if (isAcceptedFormatClear) { // accepted marker inside a *still-attributed* (pending) range for the same key — // the ambient context holds an attributor list, not a clear. Relative to the base // doc the effective format of this span is still a suggested change, so the fresh // render keeps attributing it (the `skip` case below): keep the ambient // attribution rather than clearing it. } else if (attribution == null && !sameAsPreviousAttributions) { // skip } else if (attribution == null || sameAsPreviousAttributions) { // an unattributed format was found or an attributed format // was found that resets to the previous status. When this format item is // itself rendered this transaction (`renderContent || renderDelete`) in a change/diff // render (`itemsToRender != null`), it is the END of an attributed format range: // emit an explicit clear (a `null` leaf) so the retained content drops any stale // `{ format: { [key]: [] } }` from the maintained `delta` cache — a bare context-skip // (`delete`) would leave it in place. The same applies to a *retained* attributed // boundary when a rendered same-key marker preceded it in this walk // (`renderedFormatKeys`): the boundary only resets-to-previous *because of* the // rendered change (e.g. a base-doc format arriving under an equal same-key format // suggestion — which marker comes first depends on client ids), so the spans it // governs must drop their now-stale attribution too. For a merely-retained boundary // with no rendered same-key marker, or a full insert render (removal is already // modeled as absence in `currentFormats`), just drop the key: a change render must // not emit ops for unchanged ranges, and inserts must stay free of a spurious // `{ format: { [key]: null } }`. // `c.fresh`: a marker inserted (and possibly auto-deleted) by this very change — // e.g. a base-doc format clear arriving inside a suggestion-deleted paragraph — // stays on the retained path (mode 0, the fresh-format exemption), but when it // resets-to-previous it is the range END of the attributed run in the new state // and must clear the stale attribution from the cache like a rendered marker. if (isRangeEndClear || (attribution != null && itemsToRender != null && (renderContent || renderDelete || c.fresh || renderedFormatKeys?.has(key) === true))) { changedAttributedFormats[key] = null } else { delete changedAttributedFormats[key] // pending per-key clears force this copy to install (the all-null disjunct // below); installing after silently dropping this key would close its // governance without the clear the fresh render's context-close implies, // leaving a stale `{ format: { [key]: [] } }` on the following retained // spans — re-add the key as an explicit clear. Inert outside attributing // change renders: null leaves cannot exist in fresh contexts. if (itemsToRender != null && !object.isEmpty(changedAttributedFormats) && object.every(changedAttributedFormats, v => v == null)) { changedAttributedFormats[key] = null } } delete previousUnattributedFormats[key] } else { // an attributed marker opens pending governance for the key — a deleted attributed // marker never writes `currentFormats`, hence the alive gate; an attributed marker // that value-equals the previous unattributed value takes the reset branch above // and correctly yields committed governance if (!c.deleted) currentFormatsAttributed[key] = true const by = changedAttributedFormats[key] = (changedAttributedFormats[key]?.slice() ?? []) by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? [])) const attributedAt = (c.deleted ? attribution.deleteAt : attribution.insertAt) if (attributedAt) formattingAttribution.formatAt = attributedAt } if (object.isEmpty(changedAttributedFormats)) { d.useAttribution(null) } else if (attribution != null || isDeletedFormatClear || isAcceptedFormatClear || isRangeEndClear || (itemsToRender != null && object.every(changedAttributedFormats, v => v == null))) { // the last disjunct: a change-render copy whose remaining leaves are ALL per-key // null clears is the analogue of the fresh render's empty copy (fresh contexts // never contain null leaves — they are produced only by the change-render-only // predicates above) — install it, so an unattributed close marker's key deletion // closes the governance while the pending cache clears still propagate; discarding // it would re-merge the stale key into the cache from the surviving ambient const attributedAt = (c.deleted ? attribution?.deleteAt : attribution?.insertAt) if (attributedAt != null) formattingAttribution.formatAt = attributedAt d.useAttribution(formattingAttribution) } } break } } } for (let item = this._start; item !== null; item = item.right) { const content = item.content if (renderer === null || !renderer.hasItem(item)) { // generic fast path: content the renderer doesn't claim renders as-is — no attribution // lookups, no AttributedContent wrappers and, in the common full-coverage case, no // content slicing if (content.constructor === ContentFormat) { // format markers always flow through the shared state machine processContent(new AttributedContent(content, item.id.clock, item.deleted, null, itemsToRender == null ? 1 : (itemsToRender.has(item.id.client, item.id.clock) ? 2 : 0))) } else if (item.deleted) { // plain deleted content is invisible; in a change render, the ranges deleted by this // change emit `delete` ops — position-only, the content itself is not needed if (itemsToRender !== null && itemsToRender.intersects(item.id.client, item.id.clock, item.length)) { const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length) for (let ir = 0; ir < rslice.length; ir++) { const idrange = rslice[ir] if (idrange.exists) { // mirror the piece-wise op sizes of the renderer path: string-ish content // deletes by length, other content deletes one element per piece d.delete((content.constructor === ContentString || content.constructor === ContentDeleted) ? idrange.len : 1) } } } } else if (itemsToRender == null) { if (retainInserts) { // attribution-overlay render (e.g. `toDelta({ renderer, retainInserts: true })`): // existing content is retained, clearing any formerly cached own-attribution d.useFormats(changedFormats) usingChangedFormats = true d.retain(content.getLength(), undefined, clearedOwnAttribution()) } else { // full render: a plain insert of the whole item d.useFormats(currentFormats) usingCurrentFormats = true if (deep && content.constructor === ContentType) { d.insert([/** @type {any} */(content).type.toDelta(optsAll)]) } else if (content.constructor === ContentString) { d.insert(/** @type {ContentString} */ (content).str) } else { d.insert(content.getContent()) } } } else { // change render on alive plain content: inserted ranges become inserts, everything // else retains (position-only). `retainInserts` (attribution-heal renders) retains // previously inserted content while clearing its former attribution. const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length) let itemContent = rslice.length > 1 ? content.copy() : content for (let ir = 0; ir < rslice.length; ir++) { const idrange = rslice[ir] const c = itemContent if (ir !== rslice.length - 1) { itemContent = itemContent.splice(idrange.len) } if (!idrange.exists) { if (content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (content).type)) { // @todo use current transaction instead d.modify(/** @type {ContentType} */ (content).type.toDelta(optsAll)) } else { d.useFormats(changedFormats) usingChangedFormats = true // mirror the piece-wise op sizes of the renderer path (see the delete branch) d.retain(content.constructor === ContentString ? idrange.len : 1) } } else if (retainInserts) { if (content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (content).type)) { // a de/re-attributed node (e.g. its insert-suggestion was accepted): clear its // own attribution and descend so the nested walk heals exactly the child ids // present in `itemsToRender` — children outside the change set keep their // attribution (id-scoped, never a blanket subtree clear) d.modify(/** @type {ContentType} */ (content).type.toDelta(optsAll), undefined, clearedOwnAttribution()) } else { d.useFormats(changedFormats) usingChangedFormats = true d.retain(idrange.len, undefined, clearedOwnAttribution()) } } else { d.useFormats(currentFormats) usingCurrentFormats = true if (deep && content.constructor === ContentType) { d.insert([/** @type {any} */(content).type.toDelta(optsAll)]) } else if (content.constructor === ContentString) { d.insert(/** @type {ContentString} */ (c).str) } else { d.insert(c.getContent()) } } } } } else { cs.length = 0 if (itemsToRender != null) { const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length) // fresh content deleted by the very change being rendered gets mode 3: it renders as // an insert even under `retainDeletes` — the consuming state (e.g. the maintained // `delta` cache) has never seen it, so there is nothing to retain. Freshness is // decided per id range, NOT per item: structs merge across cleanups (e.g. a queued // transaction's render runs after an earlier finally-block merged its fresh item into // an older neighbor), so one item can span fresh and pre-existing ids. ContentFormat // markers are exempt and keep mode 0 (the retained-marker path; they never merge, so // the whole-item check is exact): the format state machine treats fresh-deleted // markers exactly like before this change rendered. const checkFresh = item.deleted && insertedItems !== null && content.constructor !== ContentFormat const freshFormat = item.deleted && insertedItems !== null && content.constructor === ContentFormat && insertedItems.hasId(item.id) let itemContent = rslice.length > 1 ? content.copy() : content for (let ir = 0; ir < rslice.length; ir++) { const idrange = rslice[ir] let c = itemContent if (ir !== rslice.length - 1) { itemContent = itemContent.splice(idrange.len) } if (!idrange.exists || !checkFresh) { renderer.readContent(cs, item.id.client, idrange.clock, item.deleted, c, idrange.exists ? (freshFormat ? 0 : 2) : 0) } else { // an exists range may itself straddle fresh and pre-existing ids (ranges from // different sources merge in `itemsToRender`) — split it against `insertedItems` const fslice = insertedItems.slice(item.id.client, idrange.clock, idrange.len) let subContent = fslice.length > 1 ? c.copy() : c for (let fi = 0; fi < fslice.length; fi++) { const frange = fslice[fi] c = subContent if (fi !== fslice.length - 1) { subContent = subContent.splice(frange.len) } renderer.readContent(cs, item.id.client, frange.clock, item.deleted, c, frange.exists ? 3 : 2) } } } if (freshFormat) { // fresh format markers stay on the retained path (mode 0, see `checkFresh`), but // the format state machine must still see their freshness: a fresh clear marker // that resets-to-previous ends an attributed range and must emit the per-key // attribution clear (`cs` is reset per item; a freshFormat item is a single // len-1 ContentFormat, so this marks exactly its pieces) for (let i = 0; i < cs.length; i++) { cs[i].fresh = true } } } else { renderer.readContent(cs, item.id.client, item.id.clock, item.deleted, content, 1) } for (let i = 0; i < cs.length; i++) { processContent(cs[i]) } } } } return /** @type {any} */ (d.done(false)) } /** * Render the difference to another ydoc (which can be empty) and highlight the differences with * attributions. * * @param {Object} [opts] * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed * @return {delta.Delta} */ toDeltaDeep (opts = {}) { return /** @type {any} */ (this.toDelta({ ...opts, deep: true })) } /** * Apply a {@link Delta} on this shared type. * * @param {delta.DeltaAny} d The changes to apply on this element. * @param {any} [origin] Origin of the transaction that applies this delta (stored on * `transaction.origin` and forwarded verbatim on the emitted `'delta'` event, so listeners can * recognize — and skip — changes they produced themselves; see the lib0 `RDT` spec). Defaults to `null`. * @param {Object} [opts] * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed * @return {delta.DeltaBuilder?} The lib0 `RDT` "fix" of this apply — a change measured against the * caller's expected state (`old.apply(d)`) that transforms it into the actual state, or `null` * when `d` applied cleanly. A fix is produced when `d` (or a nested `modify`/`modifyAttr`) * addresses a *deleted but rendered* node (e.g. a suggestion-deleted paragraph under a * DiffRenderer): that part of the change is not applied to the document, and its inverse * (`lib0/delta` `inverse` against the node's rendered state) is returned — the change is * immediately reverted. * * @public */ applyDelta (d, origin = null, { renderer = this._renderer } = {}) { if (d.isEmpty()) return null if (this.doc == null) { (this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d) return null } const titem = this._item if (titem !== null && titem.deleted) { if (rendererContentLength(renderer, titem) > 0) { // deleted, but still rendered (e.g. a suggestion-deleted node): apply nothing — revert the // whole change and return its inverse (against the rendered state the caller addressed) const inv = delta.inverse(d, /** @type {any} */ (this.toDeltaDeep({ renderer }))) return inv.isEmpty() ? null : /** @type {any} */ (inv) } return null // invisible deleted type: the caller's view shows nothing here — silently drop } // @todo this was moved here from ytext. Make this more generic return transact(this.doc, transaction => { /** * The accumulated fix. Its coordinates live in the caller's *expected* space, so they are * tracked from `d`'s own ops (`expectedIndex`) — not `currPos.index`, which also counts * content that a delete over attributed-deleted ranges leaves rendered. * * @type {delta.DeltaBuilder?} */ let fix = null let fixLen = 0 let expectedIndex = 0 /** * @param {delta.DeltaAny?} childFix * @param {{ [k:string]: any }} [invFormat] */ const appendModifyFix = (childFix, invFormat) => { const f = fix ?? (fix = /** @type {any} */ (delta.create())) expectedIndex > fixLen && f.retain(expectedIndex - fixLen) f.modify(/** @type {any} */ (childFix ?? delta.create().done(false)), invFormat) fixLen = expectedIndex + 1 } const currPos = new ItemTextListPosition(null, this._start, 0, new Map(), renderer) for (const op of d.children) { if (delta.$textOp.check(op)) { insertContent(transaction, /** @type {any} */ (this), currPos, new ContentString(op.insert), op.format || {}) expectedIndex += op.length } else if (delta.$insertOp.check(op)) { insertContentHelper(transaction, this, currPos, op.insert, op.format || {}) expectedIndex += op.length } else if (delta.$retainOp.check(op)) { currPos.formatText(transaction, /** @type {any} */ (this), op.retain, op.format || {}) expectedIndex += op.length } else if (delta.$deleteOp.check(op)) { deleteText(transaction, currPos, op.delete) } else if (delta.$modifyOp.check(op)) { let item = currPos.right while (item !== null && rendererContentLength(renderer, item) === 0) { item = item.right } if (item == null || item.content.constructor !== ContentType) { error.unexpectedCase() } if (item.deleted) { // deleted but rendered: revert instead of apply. Advance the cursor first (populating // `currentFormats` with any markers up to the node) without applying `op.format`, then // recurse — the child's deleted-guard applies nothing and returns the inverse. currPos.formatText(transaction, /** @type {any} */ (this), 1, {}) /** @type {{ [k:string]: any }|undefined} */ let invFormat for (const k in op.format) { (invFormat ?? (invFormat = {}))[k] = currPos.currentFormats.get(k) ?? null } const childFix = /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer }) if (childFix !== null || invFormat !== undefined) { appendModifyFix(childFix, invFormat) } } else { const childFix = /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer }) currPos.formatText(transaction, /** @type {any} */ (this), 1, op.format || {}) if (childFix !== null) { appendModifyFix(childFix) } } expectedIndex += 1 } else { error.unexpectedCase() } } for (const op of d.attrs) { if (delta.$setAttrOp.check(op)) { nodeMapSet(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key), op.value) } else if (delta.$deleteAttrOp.check(op)) { nodeMapDelete(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key)) } else { // modifyAttr — locate the target renderer-aware: a deleted map value may still be rendered const mapItem = this._map.get(/** @type {any} */ (op.key)) const sub = mapItem === undefined ? undefined : (mapItem.deleted ? (mapItem.content.constructor === ContentType && rendererContentLength(renderer, mapItem) > 0 ? /** @type {ContentType} */ (mapItem.content).type : undefined) : mapItem.content.getContent()[mapItem.length - 1]) if (!(sub instanceof YNode)) { error.unexpectedCase() } const subFix = sub.applyDelta(op.value, origin, { renderer }) if (subFix !== null) { const f = fix ?? (fix = /** @type {any} */ (delta.create())) f.modifyAttr(/** @type {any} */ (op.key), /** @type {any} */ (subFix)) } } } return fix !== null && !(/** @type {delta.DeltaBuilder} */ (fix).done(false).isEmpty()) ? fix : null }, origin) } /** * 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 {YNode} */ clone () { const cpy = this._copy() cpy.applyDelta(this.toDeltaDeep()) return cpy } /** * Removes all elements from this YMap. */ clearAttrs () { const 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 * @template {delta.DeltaConfGetAttrs[KEY]} VAL * * @param {KEY} attributeName The attribute name that is to be set. * @param {VAL} attributeValue The attribute value that is to be set. * @return {VAL} * * @public */ setAttr (attributeName, attributeValue) { this.applyDelta(delta.create().setAttr(attributeName, attributeValue).done()) return attributeValue } /** * 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 */ getAttr (attributeName) { return /** @type {any} */ (nodeMapGet(this, attributeName)) } /** * Returns whether an attribute exists * * @param {string} attributeName The attribute name to check for existence. * @return {boolean} whether the attribute exists. * * @public */ hasAttr (attributeName) { return /** @type {any} */ (nodeMapHas(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 */ getAttrs (snapshot) { return /** @type {any} */ (snapshot ? nodeMapGetAllSnapshot(this, snapshot) : nodeMapGetAll(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. * @param {delta.Formats} [format] */ insert (index, content, format) { this.applyDelta(delta.create().retain(index).insert(/** @type {any} */ (content), format).done()) } /** * 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 {number} length The index to insert content at. * @param {delta.Formats} formats * */ format (index, length, formats) { this.applyDelta(delta.create().retain(index).retain(length, formats)) } /** * 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) } /** * @todo refactor this, this should use getContent only! * * Transforms this YArray to a JavaScript Array. * * @return {Array | delta.DeltaConfGetText>} */ toArray () { const dcontent = this.toDelta() /** * @type {Array} */ const children = [] for (const child of dcontent.children) { if (delta.$insertOp.check(child)) { for (let i = 0; i < child.insert.length; i++) { children.push(child.insert[i]) } } else if (delta.$textOp.check(child)) { children.push(child.insert) } } return children } /** * Transforms this Shared Type to a JSON object. * @return {{ name?: string, attrs?: { [K:string|number]: any }, children?: Array }} */ toJSON () { /** * @type {{[K:string]:any}} */ const attrs = this.getAttrs() for (const k in attrs) { const attr = attrs[k] attrs[k] = attr instanceof YNode ? attr.toJSON() : attr } const children = this.toArray().map(child => child instanceof YNode ? /** @type {any} */ (child.toJSON()) : child) /** * @type {any} */ const res = {} if (this.name != null) { res.name = this.name } if (this.length > 0) { res.children = children } if (this.attrSize > 0) { res.attrs = attrs } return res } /** * @param {object} opts * @param {boolean} [opts.forceTag] enforce creating a surrouning tag, even if it is null. */ toString ({ forceTag = false } = {}) { /** * @type {Array<[string|number,string]>} */ const attrs = [] this.forEachAttr((attr, key) => { attrs.push([(key), /** @type {any} */ (attr) instanceof YNode ? attr.toString({ forceTag: true }) : JSON.stringify(attr)]) }) const attrsString = (attrs.length > 0 ? ' ' : '') + attrs.sort((a, b) => a[0].toString() < b[0].toString() ? -1 : 1).map(attr => attr[0] + '=' + attr[1]).join(' ') /** * @type {string} */ const children = this.toArray().map(c => s.$string.check(c) ? c : (c instanceof YNode ? c.toString({ forceTag: true }) : JSON.stringify(c))).join('') if (this.name == null && !forceTag && attrs.length === 0) { return children } if (this.length === 0) { return `<${this.name ?? ''}${attrsString} />` } return `<${this.name ?? ''}${attrsString}>${children}` } /** * Returns an Array with the result of calling a provided function on every * child-element. * * @template M * @param {(child:delta.DeltaConfGetChildren|delta.DeltaConfGetText,index:number)=>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 this.toArray().map(f) } /** * Executes a provided function once on every element of this YArray. * * @param {(child:delta.DeltaConfGetChildren|delta.DeltaConfGetText,index:number)=>any} f Function that produces an element of the new Array */ forEach (f) { return this.toArray().forEach(f) } /** * Executes a provided function on once on every key-value pair. * * @param {(val:delta.DeltaConfGetAttrs[any],key:Exclude,symbol>,ynode:this)=>any} f */ forEachAttr (f) { this._map.forEach((item, key) => { if (!item.deleted) { f(item.content.getContent()[item.length - 1], /** @type {any} */ (key), 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[K]] }[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} */ get attrSize () { return [...createMapIterator(this)].length } /** * @param {this} other */ [traits.EqualityTraitSymbol] (other) { return this.toDelta().equals(other.toDelta()) } /** * @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 YXmlElementRefID: case YXmlHookRefID: { encoder.writeKey(this.name) break } } } } /** * Schema of a {@link YNode} with any delta configuration. This is where the nominal `y:node` tag * lives - {@link $node} builds on it. Mirrors lib0's `$deltaAny` / `$delta` split. */ export const $nodeAny = /** @type {s.Schema>} */ (YNode.prototype.$type = s.$type('y:node', YNode)) /** * Schema of a {@link YNode} with a specific delta configuration. * * **Checked at runtime:** the nominal type, and the node's `name`. `name` is assigned once in the * constructor and never reassigned, so the check is stable for the node's lifetime. * * **Not checked at runtime:** `attrs`, `children`, `text`, `formats`. A node does not carry its * `DConf` - `name` is its only runtime residue, which is why {@link YNode#$delta} returns * `$deltaAny`. Those parts are a type-level assertion, and they could not be checked soundly * anyway: a node is a *concurrently* mutable container, so a check of its content would hold only * for the instant it ran - a remote update can violate it immediately after, with no local code * involved. To validate content, validate the immutable value instead: * `delta.$delta(conf).check(node.toDelta())`. * * This is stricter than lib0's `$Delta.check`, which *skips* the name check when `name == null`. * That is right for a delta (a nameless delta asserts nothing about the name) and wrong for a node * (a nameless node is definitively a fragment). So `$node({ name: 'p' })` rejects `ydoc.get('key')`, * whose `name` is `null` unless a name was passed as the *second* argument. * * Allocates a fresh schema per call - hoist it rather than calling it in a hot loop. For a cheap * nominal gate use {@link $nodeAny} (one identity compare). * * @template {delta.ReadableDeltaConf} DConf * @param {DConf} dconf * @return {s.Schema>>} */ export const $node = dconf => { // the same coercion lib0's `$delta` applies to `conf.name` const $name = dconf.name == null ? s.$any : s.$(dconf.name) return s.$custom(o => $nodeAny.check(o) && $name.check(o.name)) } /** * @param {StructStore} store * @param {IdSet} items */ export const computeModifiedFromItems = (store, items) => { /** * @type {Map>} */ const modified = new Map() iterateStructsByIdSetWithoutSplits(store, items, /** @param {Item | GC | Skip | null} item */ item => { while (item instanceof Item) { const parent = /** @type {YNode} */ (item.parent) const conf = map.setIfUndefined(modified, parent, set.create) if (conf.has(item.parentSub)) break // has already been marked as modified conf.add(item.parentSub) item = parent._item } }) return modified } /** * The active renderer's attribution overlay changed (e.g. a suggestion was accepted or rejected) * without a Y transaction on the type's doc, so no `YEvent` fires. Re-render the affected id * ranges and keep the RDT surface current, mirroring the per-transaction upkeep in * `cleanupTransactions`: patch the maintained {@link YNode#delta} cache and emit the change on * the `'delta'` channel. Wired to the renderer's `'change'` event by {@link YNode#useRenderer}. * * Ranges in `changes` that the doc has not (yet) integrated — e.g. a base-doc edit whose update * flows in only after the renderer event — simply render to nothing here; the later transaction * covers them through the regular event path. * * The cache patch must stay on the builder `apply` — see the {@link YNode#_delta} * fingerprint-memo invariant. * * @param {YNode} type * @param {IdSet} changes * @param {any} origin * * @private * @function */ export const typeApplyRendererChange = (type, changes, origin) => { const hasDeltaListeners = (type._observers.get('delta')?.size ?? 0) > 0 // no deleted-type guard: this handler only exists while a custom renderer is attached (see // `useRenderer`), and a custom renderer may still render a deleted type — its cache and 'delta' // channel must stay current through accept/reject overlay changes. if ((type._delta === null && !hasDeltaListeners) || type.doc == null) return const change = type.toDelta({ renderer: type._renderer, deep: true, itemsToRender: changes, retainInserts: true, retainDeletes: true }) if (!change.isEmpty()) { type._delta?.apply(change) if (hasDeltaListeners) type.emit('delta', [change, origin]) } } /** * @param {any} a * @param {any} b * @return {boolean} */ export const equalFormats = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b)) /** * @template {delta.DeltaConf} DConf * @typedef {delta.DeltaConfOverwrite]: DeltaToYNode[K]> }, * children: DeltaToYNode> * }> * } DeltaConfDeltaToYNode */ /** * @template {any} Data * @typedef {Exclude | (Extract extends delta.Delta ? (unknown extends DConf ? YNode : never) : never)} DeltaToYNode */ /** * @param {YNode} 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 } /** * @todo remove / inline this * * @param {YNode} 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 } } } /** * @todo this is a duplicate. use the unified insert function and remove this. * * @param {Transaction} transaction * @param {YNode} 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, store.getClock(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, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c)))) left.integrate(transaction, 0) break default: if ($doc.check(c)) { left = new Item(createID(ownClientId, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, createContentDocFromDoc(/** @type {Doc} */ (c))) left.integrate(transaction, 0) } else if (c instanceof YNode) { left = new Item(createID(ownClientId, store.getClock(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 {YNode} 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 {YNode} 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 {YNode} 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 {YNode} parent * @param {string} key * * @private * @function */ export const nodeMapDelete = (transaction, parent, key) => { const c = parent._map.get(key) if (c !== undefined) { c.delete(transaction) } } /** * @param {Transaction} transaction * @param {YNode} parent * @param {string} key * @param {YValue} value * * @private * @function */ export const nodeMapSet = (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 default: if ($doc.check(value)) { content = createContentDocFromDoc(/** @type {Doc} */ (value)) } else if (value instanceof YNode) { content = new ContentType(/** @type {any} */ (value)) } else { throw new Error('Unexpected content type') } } } new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0) } /** * @param {YNode} parent * @param {string} key * @return {Object|number|null|Array|string|Uint8Array|YNode|undefined} * * @private * @function */ export const nodeMapGet = (parent, key) => { parent.doc ?? warnPrematureAccess() const val = parent._map.get(key) return val !== undefined && !val.deleted ? val.content.getContent()[val.length - 1] : undefined } /** * @param {YNode} parent * @return {Object|number|null|Array|string|Uint8Array|YNode|undefined>} * * @private * @function */ export const nodeMapGetAll = (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 {YNode} parent * @param {Set?} attrsToRender * @param {AbstractRenderer?} renderer * @param {boolean} deep * @param {Set|Map|null} [modified] - set of types that should be rendered as modified children * @param {IdSet?} [itemsToRender] * @param {any} [opts] * @param {any} [optsAll] * * @private * @function */ export const nodeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modified, itemsToRender, opts, optsAll) => { // @todo support modified ops! /** * @param {Item} item * @param {string} key */ const renderAttrs = (item, key) => { /** * @type {{ deleted: boolean, attrs: Array>?, content: AbstractContent }} */ let piece if (renderer === null || !renderer.hasItem(item)) { // generic fast path — the item's own fields, no renderer involved piece = { deleted: item.deleted, attrs: null, content: item.content } } else { /** * @type {Array>} */ const cs = [] renderer.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1) if (cs.length === 0) return // the renderer surfaces nothing for this attribute (e.g. a diff renderer hiding an unchanged delete) piece = cs[cs.length - 1] } const { deleted, attrs, content } = piece const attribution = createAttributionFromAttributionItems(attrs, deleted) let c = array.last(content.getContent()) if (deleted) { if (attribution != null) { // Item surfaced under attribution (suggestion view / diff renderer, either in snapshot mode // or in an event-driven render). The attribute is still observable in the rendered state, so // emit a positive `SetAttrOp` carrying the attribution metadata - matching how content // children are rendered for the same case (positive `InsertOp` with attribution, never // `DeleteOp`). // also re-emit when the change happened *inside* the still-rendered value (`modified` // contains the value type but the attr's own map item is not part of the change) — the // deleted value has no modifyAttr path, and a full-state `setAttr` replace is idempotent if (itemsToRender == null || itemsToRender.hasId(item.lastId) || (c instanceof YNode && modified != null && modified.has(c))) { if (deep && c instanceof YNode) { // full-state value render: a positive `setAttr` *replaces* the attr value on the // consuming side, so the nested type must render as its full attributed state // (change-scoped opts like `itemsToRender` would render bare retains here) c = /** @type {any} */(c).toDelta({ renderer, deep: true }) } d.setAttr(key, c, attribution) } } else if (itemsToRender != null && itemsToRender.hasId(item.lastId)) { // Hard-deleted attribute within a change render: emit the `deleteAttr` op so consumers (the // `YEvent` delta, RDT bindings, the maintained `delta` cache) can apply the removal. In // full-state mode (`itemsToRender == null`) the attribute is simply omitted (above renders // run with `render === false` for such items, so nothing was emitted before either). d.deleteAttr(key, attribution) } } else if (deep && c instanceof YNode && modified?.has(c)) { d.modifyAttr(key, c.toDelta(opts)) } else { if (deep && c instanceof YNode) { c = /** @type {any} */(c).toDelta(optsAll) } d.setAttr(key, c, attribution) } } if (attrsToRender == null) { parent._map.forEach(renderAttrs) } else { attrsToRender.forEach(key => key != null && renderAttrs(/** @type {Item} */ (parent._map.get(key)), key)) } } /** * @param {YNode} parent * @param {string} key * @return {boolean} * * @private * @function */ export const nodeMapHas = (parent, key) => { parent.doc ?? warnPrematureAccess() const val = parent._map.get(key) return val !== undefined && !val.deleted } /** * @param {Item} item * @param {Snapshot|undefined} snapshot * * @protected * @function */ export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !snapshot.ds.hasId(item.id) /** * @param {YNode} parent * @param {string} key * @param {Snapshot} snapshot * @return {Object|number|null|Array|string|Uint8Array|YNode|undefined} * * @private * @function */ export const nodeMapGetSnapshot = (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 {YNode} parent * @param {Snapshot} snapshot * @return {Object|number|null|Array|string|Uint8Array|YNode|undefined>} * * @private * @function */ export const nodeMapGetAllSnapshot = (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 {YNode & { _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) } /** * @private * * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @return {ContentType} */ export const readContentType = decoder => new ContentType(readYNode(decoder)) /** * @private * * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @return {ContentString} */ export const readContentString = decoder => new ContentString(decoder.readString()) /** * @private * * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @return {ContentJSON} */ export const readContentJSON = decoder => { const len = decoder.readLen() const cs = [] for (let i = 0; i < len; i++) { const c = decoder.readString() if (c === 'undefined') { cs.push(undefined) } else { cs.push(JSON.parse(c)) } } return new ContentJSON(cs) } /** * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @return {ContentFormat} */ export const readContentFormat = decoder => new ContentFormat(decoder.readKey(), decoder.readJSON()) /** * @private * * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @return {ContentEmbed} */ export const readContentEmbed = decoder => new ContentEmbed(decoder.readJSON()) /** * @private * * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @return {ContentDoc} */ export const readContentDoc = decoder => new ContentDoc(decoder.readString(), decoder.readAny()) /** * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @return {ContentAny} */ export const readContentAny = decoder => { const len = decoder.readLen() const cs = [] for (let i = 0; i < len; i++) { cs.push(decoder.readAny()) } return new ContentAny(cs) } /** * @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder * @return {ContentBinary} */ export const readContentBinary = decoder => new ContentBinary(decoder.readBuf()) /** * @private * * @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder * @return {ContentDeleted} */ export const readContentDeleted = decoder => new ContentDeleted(decoder.readLen()) /** * A lookup map for reading Item content. * * @type {Array} */ export const contentRefs = [ () => { error.unexpectedCase() }, // GC is not ItemContent readContentDeleted, // 1 readContentJSON, // 2 readContentBinary, // 3 readContentString, // 4 readContentEmbed, // 5 readContentFormat, // 6 readContentType, // 7 readContentAny, // 8 readContentDoc, // 9 () => { error.unexpectedCase() } // 10 - Skip is not ItemContent ] /** * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @param {number} info */ export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder) /** * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @return {YNode} * * @private * @function */ export const readYNode = decoder => { const typeRef = decoder.readTypeRef() const ynode = new YNode(typeRef === YXmlElementRefID || typeRef === YXmlHookRefID ? decoder.readKey() : null) ynode._legacyTypeRef = typeRef return ynode }