2825 lines
111 KiB
JavaScript
2825 lines
111 KiB
JavaScript
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<string,any>|Array<any>|number|null|string|Uint8Array|BigInt|YNode<any>} 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<ContentAttribute<any>>?} 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<string,any>} 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<string,any>} 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<AttributedContent<any>>}
|
|
*/
|
|
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<string,any>} 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<string,any>} 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<string,any>} 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<string,any>} formats
|
|
* @return {Map<string,any>}
|
|
*
|
|
* @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<string,any>} 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<any>|string} insert
|
|
* @param {Object<string,any>} 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<AttributedContent<any>>}
|
|
*/
|
|
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<any>} */ (/** @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<ArraySearchMarker>} 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<ArraySearchMarker>} 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<Item>}
|
|
*/
|
|
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<any>} 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<DConf>, origin: any) => void, destroy: (type: YNode<DConf>) => void }>}
|
|
*/
|
|
export class YNode extends ObservableV2 {
|
|
/**
|
|
* @param {delta.DeltaConfGetName<DConf>?} name
|
|
*/
|
|
constructor (name = null) {
|
|
super()
|
|
/**
|
|
* @type {delta.DeltaConfGetName<DConf>}
|
|
*/
|
|
this.name = /** @type {delta.DeltaConfGetName<DConf>} */ (name)
|
|
/**
|
|
* @type {Item|null}
|
|
*/
|
|
this._item = null
|
|
/**
|
|
* @type {Map<string,Item>}
|
|
*/
|
|
this._map = new Map()
|
|
/**
|
|
* @type {Item|null}
|
|
*/
|
|
this._start = null
|
|
/**
|
|
* @type {Doc|null}
|
|
*/
|
|
this.doc = null
|
|
this._length = 0
|
|
/**
|
|
* Event handlers
|
|
* @type {EventHandler<YEvent<DeltaToYNode<DConf>>,Transaction>}
|
|
*/
|
|
this._eH = createEventHandler()
|
|
/**
|
|
* Deep event handlers
|
|
* @type {EventHandler<YEvent<DConf>,Transaction>}
|
|
*/
|
|
this._dEH = createEventHandler()
|
|
/**
|
|
* @type {null | Array<ArraySearchMarker>}
|
|
*/
|
|
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<ArraySearchMarker>|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<delta.Delta<DConf>>}
|
|
*/
|
|
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<DConf>}
|
|
*/
|
|
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<DC>} d
|
|
* @return {YNode<DC>}
|
|
*/
|
|
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<DeltaToYNode<DConf>>}
|
|
*/
|
|
get change () {
|
|
return /** @type {any} */ (delta.create())
|
|
}
|
|
|
|
/**
|
|
* @return {YNode<any>?}
|
|
*/
|
|
get parent () {
|
|
return /** @type {YNode<any>?} */ (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<DConf>}
|
|
*/
|
|
_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<null|string>} 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<DeltaToYNode<DConf>>, 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<DConf>,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<DeltaToYNode<DConf>>,tr:Transaction)=>void} f Observer function
|
|
*/
|
|
unobserve (f) {
|
|
removeEventHandlerListener(this._eH, f)
|
|
}
|
|
|
|
/**
|
|
* Unregister an observer function.
|
|
*
|
|
* @param {function(YEvent<DConf>,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<YNode,Set<string|null>>|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<DConf> : delta.Delta<DeltaConfDeltaToYNode<DConf>>} 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<string|null>} */ (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<string>?}
|
|
*/
|
|
let renderedFormatKeys = null
|
|
/**
|
|
* Renderer output for a single item — a renderer may split an item into multiple
|
|
* attributed pieces.
|
|
* @type {Array<AttributedContent<any>>}
|
|
*/
|
|
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<any>} 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<any>|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<DConf>}
|
|
*/
|
|
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<any>?} 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<any>?}
|
|
*/
|
|
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<any>} */ (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<DConf>}
|
|
*/
|
|
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<keyof delta.DeltaConfGetAttrs<DConf>,symbol>} KEY
|
|
* @template {delta.DeltaConfGetAttrs<DConf>[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<keyof delta.DeltaConfGetAttrs<DConf>,symbol|number>} KEY
|
|
* @param {KEY} attributeName The attribute name that identifies the queried value.
|
|
* @return {delta.DeltaConfGetAttrs<DConf>[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<keyof delta.DeltaConfGetAttrs<DConf>,string>]?: delta.DeltaConfGetAttrs<DConf>[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.DeltaConfGetChildren<DConf>>|delta.DeltaConfGetText<DConf>} 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.DeltaConfGetChildren<DConf>>|delta.DeltaConfGetText<DConf>} 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<DConf>} 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<DConf>}
|
|
*/
|
|
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<delta.DeltaConfGetChildren<DConf>>}
|
|
*/
|
|
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.DeltaConfGetChildren<DConf> | delta.DeltaConfGetText<DConf>>}
|
|
*/
|
|
toArray () {
|
|
const dcontent = this.toDelta()
|
|
/**
|
|
* @type {Array<any>}
|
|
*/
|
|
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<any> }}
|
|
*/
|
|
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 <name /> 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}</${this.name ?? ''}>`
|
|
}
|
|
|
|
/**
|
|
* Returns an Array with the result of calling a provided function on every
|
|
* child-element.
|
|
*
|
|
* @template M
|
|
* @param {(child:delta.DeltaConfGetChildren<DConf>|delta.DeltaConfGetText<DConf>,index:number)=>M} f Function that produces an element of the new Array
|
|
* @return {Array<M>} 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<DConf>|delta.DeltaConfGetText<DConf>,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<DConf>[any],key:Exclude<keyof delta.DeltaConfGetAttrs<DConf>,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<import('lib0/ts').KeyOf<delta.DeltaConfGetAttrs<DConf>>>}
|
|
*/
|
|
attrKeys () {
|
|
return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[0])
|
|
}
|
|
|
|
/**
|
|
* Returns the values for each element in the YMap Type.
|
|
*
|
|
* @return {IterableIterator<delta.DeltaConfGetAttrs<DConf>[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<DConf>]: [K,delta.DeltaConfGetAttrs<DConf>[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<any>>} */ (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<YNode<delta.ReadDeltaConf<DConf>>>}
|
|
*/
|
|
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<YNode,Set<null|string>>}
|
|
*/
|
|
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<any>} 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<DConf, {
|
|
* attrs: { [K in keyof delta.DeltaConfGetAttrs<DConf>]: DeltaToYNode<delta.DeltaConfGetAttrs<DConf>[K]> },
|
|
* children: DeltaToYNode<delta.DeltaConfGetChildren<DConf>>
|
|
* }>
|
|
* } DeltaConfDeltaToYNode
|
|
*/
|
|
|
|
/**
|
|
* @template {any} Data
|
|
* @typedef {Exclude<Data,delta.DeltaAny> | (Extract<Data,delta.DeltaAny> extends delta.Delta<infer DConf> ? (unknown extends DConf ? YNode<DConf> : never) : never)} DeltaToYNode
|
|
*/
|
|
|
|
/**
|
|
* @param {YNode<any>} type
|
|
* @param {number} start
|
|
* @param {number} end
|
|
* @return {Array<any>}
|
|
*
|
|
* @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<YValue>} 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<Object|Array<any>|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<Object<string,any>|Array<any>|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<Object<string,any>|Array<any>|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<any>} parent
|
|
* @param {string} key
|
|
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|YNode<any>|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<any>} parent
|
|
* @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|YNode<any>|undefined>}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const nodeMapGetAll = (parent) => {
|
|
/**
|
|
* @type {Object<string,any>}
|
|
*/
|
|
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<string|null>?} attrsToRender
|
|
* @param {AbstractRenderer?} renderer
|
|
* @param {boolean} deep
|
|
* @param {Set<YNode>|Map<YNode,any>|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<ContentAttribute<any>>?, 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<AttributedContent<any>>}
|
|
*/
|
|
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<any>} 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<any>} parent
|
|
* @param {string} key
|
|
* @param {Snapshot} snapshot
|
|
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|YNode<any>|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<any>} parent
|
|
* @param {Snapshot} snapshot
|
|
* @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|YNode<any>|undefined>}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const nodeMapGetAllSnapshot = (parent, snapshot) => {
|
|
/**
|
|
* @type {Object<string,any>}
|
|
*/
|
|
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<any> & { _map: Map<string, Item> }} type
|
|
* @return {IterableIterator<Array<any>>}
|
|
*
|
|
* @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<function(UpdateDecoderV1 | UpdateDecoderV2):AbstractContent>}
|
|
*/
|
|
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
|
|
}
|