mirror of
https://github.com/yjs/yjs.git
synced 2026-02-24 12:10:16 +01:00
2066 lines
64 KiB
JavaScript
2066 lines
64 KiB
JavaScript
import {
|
|
cleanupFormattingGap,
|
|
createIdSet,
|
|
removeEventHandlerListener,
|
|
callEventHandlerListeners,
|
|
addEventHandlerListener,
|
|
createEventHandler,
|
|
getState,
|
|
isVisible,
|
|
ContentType,
|
|
createID,
|
|
ContentAny,
|
|
ContentFormat,
|
|
ContentBinary,
|
|
ContentJSON,
|
|
ContentDeleted,
|
|
ContentString,
|
|
ContentEmbed,
|
|
getItemCleanStart,
|
|
noAttributionsManager,
|
|
transact,
|
|
ContentDoc, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, createAttributionFromAttributionItems, AbstractAttributionManager // eslint-disable-line
|
|
} from './internals.js'
|
|
|
|
import * as contentType from './structs/ContentType.js'
|
|
|
|
import * as traits from 'lib0/traits'
|
|
import * as delta from 'lib0/delta'
|
|
import * as array from 'lib0/array'
|
|
import * as map from 'lib0/map'
|
|
import * as iterator from 'lib0/iterator'
|
|
import * as error from 'lib0/error'
|
|
import * as math from 'lib0/math'
|
|
import * as log from 'lib0/logging'
|
|
import * as object from 'lib0/object'
|
|
|
|
/**
|
|
* @typedef {Object<string,any>|Array<any>|number|null|string|Uint8Array|BigInt|YType<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
|
|
|
|
/**
|
|
* 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>} currentAttributes
|
|
* @param {AbstractAttributionManager} am
|
|
*/
|
|
constructor (left, right, index, currentAttributes, am) {
|
|
this.left = left
|
|
this.right = right
|
|
this.index = index
|
|
this.currentAttributes = currentAttributes
|
|
this.am = am
|
|
}
|
|
|
|
/**
|
|
* Only call this if you know that this.right is defined
|
|
*/
|
|
forward () {
|
|
if (this.right === null) {
|
|
error.unexpectedCase()
|
|
}
|
|
switch (this.right.content.constructor) {
|
|
case ContentFormat:
|
|
if (!this.right.deleted) {
|
|
updateCurrentAttributes(this.currentAttributes, /** @type {ContentFormat} */ (this.right.content))
|
|
}
|
|
break
|
|
default:
|
|
this.index += this.am.contentLength(this.right)
|
|
break
|
|
}
|
|
this.left = this.right
|
|
this.right = this.right.right
|
|
}
|
|
|
|
/**
|
|
* @param {Transaction} transaction
|
|
* @param {YType} parent
|
|
* @param {number} length
|
|
* @param {Object<string,any>} attributes
|
|
*
|
|
* @function
|
|
*/
|
|
formatText (transaction, parent, length, attributes) {
|
|
const doc = transaction.doc
|
|
const ownClientId = doc.clientID
|
|
minimizeAttributeChanges(this, attributes)
|
|
const negatedAttributes = insertAttributes(transaction, parent, this, attributes)
|
|
// iterate until first non-format or null is found
|
|
// delete all formats with attributes[format.key] != null
|
|
// also check the attributes after the first non-format as we do not want to insert redundant negated attributes there
|
|
// eslint-disable-next-line no-labels
|
|
iterationLoop: while (
|
|
this.right !== null &&
|
|
(length > 0 ||
|
|
(
|
|
negatedAttributes.size > 0 &&
|
|
((this.right.deleted && this.am.contentLength(this.right) === 0) || this.right.content.constructor === ContentFormat)
|
|
)
|
|
)
|
|
) {
|
|
switch (this.right.content.constructor) {
|
|
case ContentFormat: {
|
|
if (!this.right.deleted) {
|
|
const { key, value } = /** @type {ContentFormat} */ (this.right.content)
|
|
const attr = attributes[key]
|
|
if (attr !== undefined) {
|
|
if (equalAttrs(attr, value)) {
|
|
negatedAttributes.delete(key)
|
|
} else {
|
|
if (length === 0) {
|
|
// no need to further extend negatedAttributes
|
|
// eslint-disable-next-line no-labels
|
|
break iterationLoop
|
|
}
|
|
negatedAttributes.set(key, value)
|
|
}
|
|
this.right.delete(transaction)
|
|
} else {
|
|
this.currentAttributes.set(key, value)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
default: {
|
|
const item = this.right
|
|
const rightLen = this.am.contentLength(item)
|
|
if (length < rightLen) {
|
|
/**
|
|
* @type {Array<import('./internals.js').AttributedContent<any>>}
|
|
*/
|
|
const contents = []
|
|
this.am.readContent(contents, item.id.client, item.id.clock, item.deleted, item.content, 0)
|
|
let i = 0
|
|
for (; i < contents.length && length > 0; i++) {
|
|
const c = contents[i]
|
|
if ((!c.deleted || c.attrs != null) && c.content.isCountable()) {
|
|
length -= c.content.getLength()
|
|
}
|
|
}
|
|
if (length < 0 || (length === 0 && i !== contents.length)) {
|
|
const c = contents[--i]
|
|
getItemCleanStart(transaction, createID(item.id.client, c.clock + c.content.getLength() + length))
|
|
}
|
|
} else {
|
|
length -= rightLen
|
|
}
|
|
break
|
|
}
|
|
}
|
|
this.forward()
|
|
}
|
|
// Quill just assumes that the editor starts with a newline and that it always
|
|
// ends with a newline. We only insert that newline when a new newline is
|
|
// inserted - i.e when length is bigger than type.length
|
|
if (length > 0) {
|
|
let newlines = ''
|
|
for (; length > 0; length--) {
|
|
newlines += '\n'
|
|
}
|
|
this.right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), this.left, this.left && this.left.lastId, this.right, this.right && this.right.id, parent, null, new ContentString(newlines))
|
|
this.right.integrate(transaction, 0)
|
|
this.forward()
|
|
}
|
|
insertNegatedAttributes(transaction, parent, this, negatedAttributes)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Transaction} transaction
|
|
* @param {ItemTextListPosition} pos
|
|
* @param {number} count steps to move forward
|
|
* @return {ItemTextListPosition}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
const findNextPosition = (transaction, pos, count) => {
|
|
while (pos.right !== null && count > 0) {
|
|
switch (pos.right.content.constructor) {
|
|
case ContentFormat:
|
|
if (!pos.right.deleted) {
|
|
updateCurrentAttributes(pos.currentAttributes, /** @type {ContentFormat} */ (pos.right.content))
|
|
}
|
|
break
|
|
default:
|
|
if (!pos.right.deleted) {
|
|
if (count < pos.right.length) {
|
|
// split right
|
|
getItemCleanStart(transaction, createID(pos.right.id.client, pos.right.id.clock + count))
|
|
}
|
|
pos.index += pos.right.length
|
|
count -= pos.right.length
|
|
}
|
|
break
|
|
}
|
|
pos.left = pos.right
|
|
pos.right = pos.right.right
|
|
// pos.forward() - we don't forward because that would halve the performance because we already do the checks above
|
|
}
|
|
return pos
|
|
}
|
|
|
|
/**
|
|
* @param {Transaction} transaction
|
|
* @param {YType} parent
|
|
* @param {number} index
|
|
* @param {boolean} useSearchMarker
|
|
* @return {ItemTextListPosition}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
const findPosition = (transaction, parent, index, useSearchMarker) => {
|
|
const currentAttributes = new Map()
|
|
const marker = useSearchMarker ? findMarker(parent, index) : null
|
|
if (marker) {
|
|
const pos = new ItemTextListPosition(marker.p.left, marker.p, marker.index, currentAttributes, noAttributionsManager)
|
|
return findNextPosition(transaction, pos, index - marker.index)
|
|
} else {
|
|
const pos = new ItemTextListPosition(null, parent._start, 0, currentAttributes, noAttributionsManager)
|
|
return findNextPosition(transaction, pos, index)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Negate applied formats
|
|
*
|
|
* @param {Transaction} transaction
|
|
* @param {YType} parent
|
|
* @param {ItemTextListPosition} currPos
|
|
* @param {Map<string,any>} negatedAttributes
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
const insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes) => {
|
|
// check if we really need to remove attributes
|
|
while (
|
|
currPos.right !== null && (
|
|
(currPos.right.deleted && (currPos.am === noAttributionsManager || currPos.am.contentLength(currPos.right) === 0)) || (
|
|
currPos.right.content.constructor === ContentFormat &&
|
|
equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (currPos.right.content).key), /** @type {ContentFormat} */ (currPos.right.content).value)
|
|
)
|
|
)
|
|
) {
|
|
if (!currPos.right.deleted) {
|
|
negatedAttributes.delete(/** @type {ContentFormat} */ (currPos.right.content).key)
|
|
}
|
|
currPos.forward()
|
|
}
|
|
const doc = transaction.doc
|
|
const ownClientId = doc.clientID
|
|
negatedAttributes.forEach((val, key) => {
|
|
const left = currPos.left
|
|
const right = currPos.right
|
|
const nextFormat = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
|
|
nextFormat.integrate(transaction, 0)
|
|
currPos.right = nextFormat
|
|
currPos.forward()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {Map<string,any>} currentAttributes
|
|
* @param {ContentFormat} format
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
const updateCurrentAttributes = (currentAttributes, format) => {
|
|
const { key, value } = format
|
|
if (value === null) {
|
|
currentAttributes.delete(key)
|
|
} else {
|
|
currentAttributes.set(key, value)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {ItemTextListPosition} currPos
|
|
* @param {Object<string,any>} attributes
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
const minimizeAttributeChanges = (currPos, attributes) => {
|
|
// go right while attributes[right.key] === right.value (or right is deleted)
|
|
while (true) {
|
|
if (currPos.right === null) {
|
|
break
|
|
} else if (currPos.right.deleted ? (currPos.am.contentLength(currPos.right) === 0) : (!currPos.right.deleted && currPos.right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (currPos.right.content)).key] ?? null, /** @type {ContentFormat} */ (currPos.right.content).value))) {
|
|
//
|
|
} else {
|
|
break
|
|
}
|
|
currPos.forward()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Transaction} transaction
|
|
* @param {YType} parent
|
|
* @param {ItemTextListPosition} currPos
|
|
* @param {Object<string,any>} attributes
|
|
* @return {Map<string,any>}
|
|
*
|
|
* @private
|
|
* @function
|
|
**/
|
|
const insertAttributes = (transaction, parent, currPos, attributes) => {
|
|
const doc = transaction.doc
|
|
const ownClientId = doc.clientID
|
|
const negatedAttributes = new Map()
|
|
// insert format-start items
|
|
for (const key in attributes) {
|
|
const val = attributes[key]
|
|
const currentVal = currPos.currentAttributes.get(key) ?? null
|
|
if (!equalAttrs(currentVal, val)) {
|
|
// save negated attribute (set null if currentVal undefined)
|
|
negatedAttributes.set(key, currentVal)
|
|
const { left, right } = currPos
|
|
currPos.right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
|
|
currPos.right.integrate(transaction, 0)
|
|
currPos.forward()
|
|
}
|
|
}
|
|
return negatedAttributes
|
|
}
|
|
|
|
/**
|
|
* @param {Transaction} transaction
|
|
* @param {YType} parent
|
|
* @param {ItemTextListPosition} currPos
|
|
* @param {string|object|YType} text
|
|
* @param {Object<string,any>} attributes
|
|
*
|
|
* @private
|
|
* @function
|
|
**/
|
|
export const insertText = (transaction, parent, currPos, text, attributes) => {
|
|
currPos.currentAttributes.forEach((_val, key) => {
|
|
if (attributes[key] === undefined) {
|
|
attributes[key] = null
|
|
}
|
|
})
|
|
const doc = transaction.doc
|
|
const ownClientId = doc.clientID
|
|
minimizeAttributeChanges(currPos, attributes)
|
|
const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes)
|
|
// insert content
|
|
const content = text.constructor === String ? new ContentString(/** @type {string} */ (text)) : (text instanceof YType ? new ContentType(text) : new ContentEmbed(text))
|
|
let { left, right, index } = currPos
|
|
if (parent._searchMarker) {
|
|
updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength())
|
|
}
|
|
right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content)
|
|
right.integrate(transaction, 0)
|
|
currPos.right = right
|
|
currPos.index = index
|
|
currPos.forward()
|
|
insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
|
|
}
|
|
|
|
/**
|
|
* @param {Transaction} transaction
|
|
* @param {ItemTextListPosition} currPos
|
|
* @param {number} length
|
|
* @return {ItemTextListPosition}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const deleteText = (transaction, currPos, length) => {
|
|
const startLength = length
|
|
const startAttrs = map.copy(currPos.currentAttributes)
|
|
const start = currPos.right
|
|
while (length > 0 && currPos.right !== null) {
|
|
if (!currPos.right.deleted) {
|
|
switch (currPos.right.content.constructor) {
|
|
case ContentType:
|
|
case ContentEmbed:
|
|
case ContentString:
|
|
if (length < currPos.right.length) {
|
|
getItemCleanStart(transaction, createID(currPos.right.id.client, currPos.right.id.clock + length))
|
|
}
|
|
length -= currPos.right.length
|
|
currPos.right.delete(transaction)
|
|
break
|
|
}
|
|
} else if (currPos.am !== noAttributionsManager) {
|
|
const item = currPos.right
|
|
/**
|
|
* @type {Array<import('./internals.js').AttributedContent<any>>}
|
|
*/
|
|
const contents = []
|
|
currPos.am.readContent(contents, item.id.client, item.id.clock, true, item.content, 0)
|
|
for (let i = 0; i < contents.length; i++) {
|
|
const c = contents[i]
|
|
if (c.content.isCountable() && c.attrs != null) {
|
|
// deleting already deleted content. store that information in a meta property, but do
|
|
// nothing
|
|
const contentLen = math.min(c.content.getLength(), length)
|
|
map.setIfUndefined(transaction.meta, 'attributedDeletes', createIdSet).add(item.id.client, c.clock, contentLen)
|
|
length -= contentLen
|
|
}
|
|
}
|
|
const lastContent = contents.length > 0 ? contents[contents.length - 1] : null
|
|
const nextItemClock = item.id.clock + item.length
|
|
const nextContentClock = lastContent != null ? lastContent.clock + lastContent.content.getLength() : nextItemClock
|
|
if (nextContentClock < nextItemClock) {
|
|
getItemCleanStart(transaction, createID(item.id.client, nextContentClock))
|
|
}
|
|
}
|
|
currPos.forward()
|
|
}
|
|
if (start) {
|
|
cleanupFormattingGap(transaction, start, currPos.right, startAttrs, currPos.currentAttributes)
|
|
}
|
|
const parent = /** @type {YType<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 {YType} yarray
|
|
* @param {number} index
|
|
*/
|
|
export const findMarker = (yarray, index) => {
|
|
if (yarray._start === null || index === 0 || yarray._searchMarker === null) {
|
|
return null
|
|
}
|
|
const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b)
|
|
let p = yarray._start
|
|
let pindex = 0
|
|
if (marker !== null) {
|
|
p = marker.p
|
|
pindex = marker.index
|
|
refreshMarkerTimestamp(marker) // we used it, we might need to use it again
|
|
}
|
|
// iterate to right if possible
|
|
while (p.right !== null && pindex < index) {
|
|
if (!p.deleted && p.countable) {
|
|
if (index < pindex + p.length) {
|
|
break
|
|
}
|
|
pindex += p.length
|
|
}
|
|
p = p.right
|
|
}
|
|
// iterate to left if necessary (might be that pindex > index)
|
|
while (p.left !== null && pindex > index) {
|
|
p = p.left
|
|
if (!p.deleted && p.countable) {
|
|
pindex -= p.length
|
|
}
|
|
}
|
|
// we want to make sure that p can't be merged with left, because that would screw up everything
|
|
// in that cas just return what we have (it is most likely the best marker anyway)
|
|
// iterate to left until p can't be merged with left
|
|
while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) {
|
|
p = p.left
|
|
if (!p.deleted && p.countable) {
|
|
pindex -= p.length
|
|
}
|
|
}
|
|
if (marker !== null && math.abs(marker.index - pindex) < /** @type {any} */ (p.parent).length / maxSearchMarker) {
|
|
// adjust existing marker
|
|
overwriteMarker(marker, p, pindex)
|
|
return marker
|
|
} else {
|
|
// create new marker
|
|
return markPosition(yarray._searchMarker, p, pindex)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update markers when a change happened.
|
|
*
|
|
* This should be called before doing a deletion!
|
|
*
|
|
* @param {Array<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 {YType} t
|
|
* @return {Array<Item>}
|
|
*/
|
|
export const getTypeChildren = t => {
|
|
t.doc ?? warnPrematureAccess()
|
|
let s = t._start
|
|
const arr = []
|
|
while (s) {
|
|
arr.push(s)
|
|
s = s.right
|
|
}
|
|
return arr
|
|
}
|
|
|
|
/**
|
|
* Call event listeners with an event. This will also add an event to all
|
|
* parents (for `.observeDeep` handlers).
|
|
*
|
|
* @param {YType} type
|
|
* @param {Transaction} transaction
|
|
* @param {YEvent<any>} event
|
|
*/
|
|
export const callTypeObservers = (type, transaction, event) => {
|
|
const changedType = type
|
|
const changedParentTypes = transaction.changedParentTypes
|
|
while (true) {
|
|
// @ts-ignore
|
|
map.setIfUndefined(changedParentTypes, type, () => []).push(event)
|
|
if (type._item === null) {
|
|
break
|
|
}
|
|
type = /** @type {YType} */ (type._item.parent)
|
|
}
|
|
callEventHandlerListeners(/** @type {any} */ (changedType._eH), event, transaction)
|
|
}
|
|
|
|
/**
|
|
* Abstract Yjs Type class
|
|
* @template {delta.DeltaConf} [DConf=any]
|
|
*/
|
|
export class YType {
|
|
/**
|
|
* @param {delta.DeltaConfGetName<DConf>?} name
|
|
*/
|
|
constructor (name = null) {
|
|
/**
|
|
* @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<DeltaToYType<DConf>>,Transaction>}
|
|
*/
|
|
this._eH = createEventHandler()
|
|
/**
|
|
* Deep event handlers
|
|
* @type {EventHandler<YEvent<DConf>,Transaction>}
|
|
*/
|
|
this._dEH = createEventHandler()
|
|
/**
|
|
* @type {null | Array<ArraySearchMarker>}
|
|
*/
|
|
this._searchMarker = null
|
|
/**
|
|
* @type {delta.DeltaBuilder<DConf>}
|
|
* @private
|
|
*/
|
|
this._content = /** @type {delta.DeltaBuilderAny} */ (delta.create())
|
|
this._legacyTypeRef = this.name == null ? contentType.YXmlFragmentRefID : contentType.YXmlElementRefID
|
|
/**
|
|
* @type {Array<ArraySearchMarker>|null}
|
|
*/
|
|
this._searchMarker = []
|
|
/**
|
|
* Whether this YText contains formatting attributes.
|
|
* This flag is updated when a formatting item is integrated (see ContentFormat.integrate)
|
|
*/
|
|
this._hasFormatting = false
|
|
}
|
|
|
|
get length () {
|
|
this.doc ?? warnPrematureAccess()
|
|
return this._length
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns a fresh delta that can be used to change this YType.
|
|
* @type {delta.DeltaBuilder<DeltaToYType<DConf>>}
|
|
*/
|
|
get change () {
|
|
return /** @type {any} */ (delta.create())
|
|
}
|
|
|
|
/**
|
|
* @return {YType<any>?}
|
|
*/
|
|
get parent () {
|
|
return /** @type {YType<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 {YType<DConf>}
|
|
*/
|
|
_copy () {
|
|
return new YType(this.name)
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
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<DeltaToYType<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<DeltaToYType<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 {AbstractAttributionManager} am
|
|
* @param {Object} [opts]
|
|
* @param {import('./utils/IdSet.js').IdSet?} [opts.itemsToRender]
|
|
* @param {boolean} [opts.retainInserts] - if true, retain rendered inserts with attributions
|
|
* @param {boolean} [opts.retainDeletes] - if true, retain rendered+attributed deletes only
|
|
* @param {import('./utils/IdSet.js').IdSet?} [opts.deletedItems] - used for computing prevItem in attributes
|
|
* @param {Map<YType,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<DeltaConfDeltaToYType<DConf>>} The Delta representation of this type.
|
|
*
|
|
* @public
|
|
*/
|
|
getContent (am = noAttributionsManager, opts = {}) {
|
|
const { itemsToRender = null, retainInserts = false, retainDeletes = false, deletedItems = null, modified = null, deep = false } = opts
|
|
const renderAttrs = modified?.get(this) || null
|
|
const renderChildren = !!(modified == null || modified.get(this)?.has(null))
|
|
/**
|
|
* @type {delta.DeltaBuilderAny}
|
|
*/
|
|
const d = /** @type {any} */ (delta.create(/** @type {any} */ (this).nodeName || null))
|
|
const optsAll = modified == null ? opts : object.assign({}, opts, { modified: null })
|
|
typeMapGetDelta(d, /** @type {any} */ (this), renderAttrs, am, deep, modified, deletedItems, itemsToRender, opts, optsAll)
|
|
if (renderChildren) {
|
|
/**
|
|
* @type {delta.FormattingAttributes}
|
|
*/
|
|
let currentAttributes = {} // saves all current attributes for insert
|
|
let usingCurrentAttributes = false
|
|
/**
|
|
* @type {delta.FormattingAttributes}
|
|
*/
|
|
let changedAttributes = {} // saves changed attributes for retain
|
|
let usingChangedAttributes = false
|
|
/**
|
|
* Logic for formatting attribute attribution
|
|
* Everything that comes after an formatting attribute is formatted by the user that created it.
|
|
* Two exceptions:
|
|
* - the user resets formatting to the previously known formatting that is not attributed
|
|
* - the user deletes a formatting attribute and hence restores the previously known formatting
|
|
* that is not attributed.
|
|
* @type {delta.FormattingAttributes}
|
|
*/
|
|
const previousUnattributedAttributes = {} // contains previously known unattributed formatting
|
|
/**
|
|
* @type {delta.FormattingAttributes}
|
|
*/
|
|
const previousAttributes = {} // The value before changes
|
|
/**
|
|
* @type {Array<import('./internals.js').AttributedContent<any>>}
|
|
*/
|
|
const cs = []
|
|
for (let item = this._start; item !== null; cs.length = 0) {
|
|
if (itemsToRender != null) {
|
|
for (; item !== null && cs.length < 50; item = item.right) {
|
|
const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length)
|
|
let itemContent = rslice.length > 1 ? item.content.copy() : item.content
|
|
for (let ir = 0; ir < rslice.length; ir++) {
|
|
const idrange = rslice[ir]
|
|
const content = itemContent
|
|
if (ir !== rslice.length - 1) {
|
|
itemContent = itemContent.splice(idrange.len)
|
|
}
|
|
am.readContent(cs, item.id.client, idrange.clock, item.deleted, content, idrange.exists ? 2 : 0)
|
|
}
|
|
}
|
|
} else {
|
|
for (; item !== null && cs.length < 50; item = item.right) {
|
|
am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1)
|
|
}
|
|
}
|
|
for (let i = 0; i < cs.length; i++) {
|
|
const c = cs[i]
|
|
// render (attributed) content even if it was deleted
|
|
const renderContent = c.render && (!c.deleted || c.attrs != null)
|
|
// content that was just deleted. It is not rendered as an insertion, because it doesn't
|
|
// have any attributes.
|
|
const renderDelete = c.render && c.deleted
|
|
// existing content that should be retained, only adding changed attributes
|
|
const retainContent = !c.render && (!c.deleted || c.attrs != null)
|
|
const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : null
|
|
switch (c.content.constructor) {
|
|
case ContentDeleted: {
|
|
if (renderDelete) d.delete(c.content.getLength())
|
|
break
|
|
}
|
|
case ContentString:
|
|
if (renderContent) {
|
|
d.usedAttributes = currentAttributes
|
|
usingCurrentAttributes = true
|
|
if (c.deleted ? retainDeletes : retainInserts) {
|
|
d.retain(/** @type {ContentString} */ (c.content).str.length, null, attribution ?? {})
|
|
} else {
|
|
d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
|
|
}
|
|
} else if (renderDelete) {
|
|
d.delete(c.content.getLength())
|
|
} else if (retainContent) {
|
|
d.usedAttributes = changedAttributes
|
|
usingChangedAttributes = true
|
|
d.retain(c.content.getLength())
|
|
}
|
|
break
|
|
case ContentEmbed:
|
|
case ContentAny:
|
|
case ContentJSON:
|
|
case ContentType:
|
|
case ContentBinary:
|
|
if (renderContent) {
|
|
d.usedAttributes = currentAttributes
|
|
usingCurrentAttributes = true
|
|
if (c.deleted ? retainDeletes : retainInserts) {
|
|
d.retain(c.content.getLength(), null, attribution ?? {})
|
|
} else if (deep && c.content.constructor === ContentType) {
|
|
d.insert([/** @type {any} */(c.content).type.getContent(am, optsAll)], null, attribution)
|
|
} else {
|
|
d.insert(c.content.getContent(), null, attribution)
|
|
}
|
|
} else if (renderDelete) {
|
|
d.delete(1)
|
|
} else if (retainContent) {
|
|
if (c.content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (c.content).type)) {
|
|
// @todo use current transaction instead
|
|
d.modify(/** @type {any} */ (c.content).type.getContent(am, opts))
|
|
} else {
|
|
d.usedAttributes = changedAttributes
|
|
usingChangedAttributes = true
|
|
d.retain(1)
|
|
}
|
|
}
|
|
break
|
|
case ContentFormat: {
|
|
const { key, value } = /** @type {ContentFormat} */ (c.content)
|
|
const currAttrVal = currentAttributes[key] ?? null
|
|
if (attribution != null && (c.deleted || !object.hasProperty(previousUnattributedAttributes, key))) {
|
|
previousUnattributedAttributes[key] = c.deleted ? value : currAttrVal
|
|
}
|
|
// @todo write a function "updateCurrentAttributes" and "updateChangedAttributes"
|
|
// # Update Attributes
|
|
if (renderContent || renderDelete) {
|
|
// create fresh references
|
|
if (usingCurrentAttributes) {
|
|
currentAttributes = object.assign({}, currentAttributes)
|
|
usingCurrentAttributes = false
|
|
}
|
|
if (usingChangedAttributes) {
|
|
usingChangedAttributes = false
|
|
changedAttributes = object.assign({}, changedAttributes)
|
|
}
|
|
}
|
|
if (renderContent || renderDelete) {
|
|
if (c.deleted) {
|
|
// content was deleted, but is possibly attributed
|
|
if (!equalAttrs(value, currAttrVal)) { // do nothing if nothing changed
|
|
if (equalAttrs(currAttrVal, previousAttributes[key] ?? null) && changedAttributes[key] !== undefined) {
|
|
delete changedAttributes[key]
|
|
} else {
|
|
changedAttributes[key] = currAttrVal
|
|
}
|
|
// current attributes doesn't change
|
|
previousAttributes[key] = value
|
|
}
|
|
} else { // !c.deleted
|
|
// content was inserted, and is possibly attributed
|
|
if (equalAttrs(value, currAttrVal)) {
|
|
// item.delete(transaction)
|
|
} else if (equalAttrs(value, previousAttributes[key] ?? null)) {
|
|
delete changedAttributes[key]
|
|
} else {
|
|
changedAttributes[key] = value
|
|
}
|
|
if (value == null) {
|
|
delete currentAttributes[key]
|
|
} else {
|
|
currentAttributes[key] = value
|
|
}
|
|
}
|
|
} else if (retainContent && !c.deleted) {
|
|
// fresh reference to currentAttributes only
|
|
if (usingCurrentAttributes) {
|
|
currentAttributes = object.assign({}, currentAttributes)
|
|
usingCurrentAttributes = false
|
|
}
|
|
if (usingChangedAttributes && changedAttributes[key] !== undefined) {
|
|
usingChangedAttributes = false
|
|
changedAttributes = object.assign({}, changedAttributes)
|
|
}
|
|
if (value == null) {
|
|
delete currentAttributes[key]
|
|
} else {
|
|
currentAttributes[key] = value
|
|
}
|
|
delete changedAttributes[key]
|
|
previousAttributes[key] = value
|
|
}
|
|
// # Update Attributions
|
|
if (attribution != null || object.hasProperty(previousUnattributedAttributes, key)) {
|
|
/**
|
|
* @type {import('./utils/AttributionManager.js').Attribution}
|
|
*/
|
|
const formattingAttribution = object.assign({}, d.usedAttribution)
|
|
const changedAttributedAttributes = /** @type {{ [key: string]: Array<any> }} */ (formattingAttribution.format = object.assign({}, formattingAttribution.format ?? {}))
|
|
if (attribution == null || equalAttrs(previousUnattributedAttributes[key], currentAttributes[key] ?? null)) {
|
|
// an unattributed formatting attribute was found or an attributed formatting
|
|
// attribute was found that resets to the previous status
|
|
delete changedAttributedAttributes[key]
|
|
delete previousUnattributedAttributes[key]
|
|
} else {
|
|
const by = changedAttributedAttributes[key] = (changedAttributedAttributes[key]?.slice() ?? [])
|
|
by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? []))
|
|
const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt)
|
|
if (attributedAt) formattingAttribution.formatAt = attributedAt
|
|
}
|
|
if (object.isEmpty(changedAttributedAttributes)) {
|
|
d.useAttribution(null)
|
|
} else if (attribution != null) {
|
|
const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt)
|
|
if (attributedAt != null) formattingAttribution.formatAt = attributedAt
|
|
d.useAttribution(formattingAttribution)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return /** @type {any} */ (d.done(false))
|
|
}
|
|
|
|
/**
|
|
* Render the difference to another ydoc (which can be empty) and highlight the differences with
|
|
* attributions.
|
|
*
|
|
* @param {AbstractAttributionManager} am
|
|
* @return {delta.Delta<DConf>}
|
|
*/
|
|
getContentDeep (am = noAttributionsManager) {
|
|
return /** @type {any} */ (this.getContent(am, { deep: true }))
|
|
}
|
|
|
|
/**
|
|
* Apply a {@link Delta} on this shared type.
|
|
*
|
|
* @param {delta.DeltaAny} d The changes to apply on this element.
|
|
* @param {AbstractAttributionManager} am
|
|
*
|
|
* @public
|
|
*/
|
|
applyDelta (d, am = noAttributionsManager) {
|
|
if (this.doc == null) {
|
|
(this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d)
|
|
} else {
|
|
// @todo this was moved here from ytext. Make this more generic
|
|
transact(this.doc, transaction => {
|
|
const currPos = new ItemTextListPosition(null, this._start, 0, new Map(), am)
|
|
for (const op of d.children) {
|
|
if (delta.$textOp.check(op)) {
|
|
insertText(transaction, /** @type {any} */ (this), currPos, op.insert, op.format || {})
|
|
} else if (delta.$insertOp.check(op)) {
|
|
for (let i = 0; i < op.insert.length; i++) {
|
|
let ins = op.insert[i]
|
|
if (delta.$deltaAny.check(ins)) {
|
|
if (ins.name != null) {
|
|
const t = new YType(ins.name)
|
|
t.applyDelta(ins)
|
|
ins = t
|
|
} else {
|
|
error.unexpectedCase()
|
|
}
|
|
}
|
|
insertText(transaction, /** @type {any} */ (this), currPos, ins, op.format || {})
|
|
}
|
|
} else if (delta.$retainOp.check(op)) {
|
|
currPos.formatText(transaction, /** @type {any} */ (this), op.retain, op.format || {})
|
|
} else if (delta.$deleteOp.check(op)) {
|
|
deleteText(transaction, currPos, op.delete)
|
|
} else if (delta.$modifyOp.check(op)) {
|
|
if (currPos.right) {
|
|
/** @type {ContentType} */ (currPos.right.content).type.applyDelta(op.value)
|
|
} else {
|
|
error.unexpectedCase()
|
|
}
|
|
currPos.formatText(transaction, /** @type {any} */ (this), 1, op.format || {})
|
|
} else {
|
|
error.unexpectedCase()
|
|
}
|
|
}
|
|
for (const op of d.attrs) {
|
|
if (delta.$setAttrOp.check(op)) {
|
|
typeMapSet(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key), op.value)
|
|
} else if (delta.$deleteOp.check(op)) {
|
|
typeMapDelete(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key))
|
|
} else {
|
|
const sub = typeMapGet(/** @type {any} */ (this), /** @type {any} */ (op.key))
|
|
if (!(sub instanceof YType)) {
|
|
error.unexpectedCase()
|
|
}
|
|
sub.applyDelta(op.value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Makes a copy of this data type that can be included somewhere else.
|
|
*
|
|
* Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
|
|
*
|
|
* @return {YType<DConf>}
|
|
*/
|
|
clone () {
|
|
const cpy = this._copy()
|
|
cpy.applyDelta(this.getContentDeep())
|
|
return cpy
|
|
}
|
|
|
|
/**
|
|
* Removes all elements from this YMap.
|
|
*/
|
|
clearAttrs () {
|
|
let d = delta.create()
|
|
this.forEachAttr((_,key) => {
|
|
d.deleteAttr(/** @type {any} */ (key))
|
|
})
|
|
this.applyDelta(d)
|
|
}
|
|
|
|
|
|
/**
|
|
* Removes an attribute from this YXmlElement.
|
|
*
|
|
* @param {string} attributeName The attribute name that is to be removed.
|
|
*
|
|
* @public
|
|
*/
|
|
deleteAttr (attributeName) {
|
|
this.applyDelta(delta.create().deleteAttr(attributeName).done())
|
|
}
|
|
|
|
/**
|
|
* Sets or updates an attribute.
|
|
*
|
|
* @template {Exclude<keyof delta.DeltaConfGetAttrs<DConf>,symbol>} KEY
|
|
*
|
|
* @param {KEY} attributeName The attribute name that is to be set.
|
|
* @param {delta.DeltaConfGetAttrs<DConf>[KEY]} attributeValue The attribute value that is to be set.
|
|
*
|
|
* @public
|
|
*/
|
|
setAttribute (attributeName, attributeValue) {
|
|
this.applyDelta(delta.create().setAttr(attributeName, attributeValue).done())
|
|
}
|
|
|
|
/**
|
|
* Returns an attribute value that belongs to the attribute name.
|
|
*
|
|
* @template {Exclude<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
|
|
*/
|
|
getAttribute (attributeName) {
|
|
return /** @type {any} */ (typeMapGet(this, attributeName))
|
|
}
|
|
|
|
/**
|
|
* Returns whether an attribute exists
|
|
*
|
|
* @param {string} attributeName The attribute name to check for existence.
|
|
* @return {boolean} whether the attribute exists.
|
|
*
|
|
* @public
|
|
*/
|
|
hasAttribute (attributeName) {
|
|
return /** @type {any} */ (typeMapHas(this, attributeName))
|
|
}
|
|
|
|
/**
|
|
* Returns all attribute name/value pairs in a JSON Object.
|
|
*
|
|
* @param {Snapshot} [snapshot]
|
|
* @return {{ [Key in Extract<keyof delta.DeltaConfGetAttrs<DConf>,string>]?: delta.DeltaConfGetAttrs<DConf>[Key]}} A JSON Object that describes the attributes.
|
|
*
|
|
* @public
|
|
*/
|
|
getAttributes (snapshot) {
|
|
return /** @type {any} */ (snapshot ? typeMapGetAllSnapshot(this, snapshot) : typeMapGetAll(this))
|
|
}
|
|
|
|
/**
|
|
* Inserts new content at an index.
|
|
*
|
|
* Important: This function expects an array of content. Not just a content
|
|
* object. The reason for this "weirdness" is that inserting several elements
|
|
* is very efficient when it is done as a single operation.
|
|
*
|
|
* @example
|
|
* // Insert character 'a' at position 0
|
|
* yarray.insert(0, ['a'])
|
|
* // Insert numbers 1, 2 at position 1
|
|
* yarray.insert(1, [1, 2])
|
|
*
|
|
* @param {number} index The index to insert content at.
|
|
* @param {Array<delta.DeltaConfGetChildren<DConf>>|delta.DeltaConfGetText<DConf>} content Array of content to append.
|
|
*/
|
|
insert (index, content) {
|
|
this.applyDelta(delta.create().retain(index).insert(/** @type {any} */ (content)))
|
|
}
|
|
|
|
/**
|
|
* Appends content to this YArray.
|
|
*
|
|
* @param {Array<delta.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)
|
|
}
|
|
|
|
|
|
/**
|
|
* Transforms this YArray to a JavaScript Array.
|
|
*
|
|
* @return {Array<delta.DeltaConfGetChildren<DConf>>}
|
|
*/
|
|
toArray () {
|
|
return typeListToArray(this)
|
|
}
|
|
|
|
/**
|
|
* Transforms this Shared Type to a JSON object.
|
|
*/
|
|
toJSON () {
|
|
return this.getContent().toJSON()
|
|
}
|
|
|
|
/**
|
|
* Returns an Array with the result of calling a provided function on every
|
|
* child-element.
|
|
*
|
|
* @template M
|
|
* @param {(child:delta.DeltaConfGetChildren<DConf>,index:number,ytype:this)=>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 typeListMap(this, /** @type {any} */ (f))
|
|
}
|
|
|
|
/**
|
|
* Executes a provided function once on every element of this YArray.
|
|
*
|
|
* @template M
|
|
* @param {(child:delta.DeltaConfGetChildren<DConf>,index:number,ytype:this)=>M} f Function that produces an element of the new Array
|
|
*/
|
|
forEach (f) {
|
|
typeListForEach(this, f)
|
|
}
|
|
|
|
/**
|
|
* Executes a provided function on once on every key-value pair.
|
|
*
|
|
* @param {(val:delta.DeltaConfGetAttrs<DConf>[any],key:keyof delta.DeltaConfGetAttrs<DConf>,ytype:this)=>any} f
|
|
*/
|
|
forEachAttr (f) {
|
|
this._map.forEach((item, key) => {
|
|
if (!item.deleted) {
|
|
f(item.content.getContent()[item.length - 1], /** @type {any} */ (key), this)
|
|
}
|
|
})
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* @return {IterableIterator<delta.DeltaConfGetChildren<DConf>>}
|
|
*/
|
|
[Symbol.iterator] () {
|
|
return typeListCreateIterator(this)
|
|
}
|
|
|
|
/**
|
|
* Returns the keys for each element in the YMap Type.
|
|
*
|
|
* @return {IterableIterator<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>] }[any]>}
|
|
*/
|
|
attrEntries () {
|
|
return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => /** @type {any} */ ([v[0], v[1].content.getContent()[v[1].length - 1]]))
|
|
}
|
|
|
|
/**
|
|
* Returns the number of stored attributes (count of key/value pairs)
|
|
*
|
|
* @return {number}
|
|
*/
|
|
attrSize () {
|
|
return [...createMapIterator(this)].length
|
|
}
|
|
|
|
/**
|
|
* @param {this} other
|
|
*/
|
|
[traits.EqualityTraitSymbol] (other) {
|
|
return this.getContent().equals(other.getContent())
|
|
}
|
|
|
|
/**
|
|
* @todo this doesn't need to live in a method.
|
|
*
|
|
* Transform the properties of this type to binary and write it to an
|
|
* BinaryEncoder.
|
|
*
|
|
* This is called when this Item is sent to a remote peer.
|
|
*
|
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
|
|
*/
|
|
_write (encoder) {
|
|
encoder.writeTypeRef(this._legacyTypeRef)
|
|
switch (this._legacyTypeRef) {
|
|
case contentType.YXmlElementRefID:
|
|
case contentType.YXmlHookRefID: {
|
|
encoder.writeKey(this.name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('./utils/UpdateDecoder.js').UpdateDecoderV1 | import('./utils/UpdateDecoder.js').UpdateDecoderV2} decoder
|
|
* @return {YType}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const readYType = decoder => {
|
|
const typeRef = decoder.readTypeRef()
|
|
const ytype = new YType(typeRef === contentType.YXmlElementRefID || typeRef === contentType.YXmlHookRefID ? decoder.readKey() : null)
|
|
ytype._legacyTypeRef = typeRef
|
|
return ytype
|
|
}
|
|
|
|
/**
|
|
* @param {any} a
|
|
* @param {any} b
|
|
* @return {boolean}
|
|
*/
|
|
export const equalAttrs = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))
|
|
|
|
/**
|
|
* @template {delta.DeltaConf} DConf
|
|
* @typedef {delta.DeltaConfOverwrite<DConf, {
|
|
* attrs: { [K in keyof delta.DeltaConfGetAttrs<DConf>]: DeltaToYType<delta.DeltaConfGetAttrs<DConf>[K]> },
|
|
* children: DeltaToYType<delta.DeltaConfGetChildren<DConf>>
|
|
* }>
|
|
* } DeltaConfDeltaToYType
|
|
*/
|
|
|
|
/**
|
|
* @template {any} Data
|
|
* @typedef {Exclude<Data,delta.DeltaAny> | (Extract<Data,delta.DeltaAny> extends delta.Delta<infer DConf> ? (unknown extends DConf ? YType<DConf> : never) : never)} DeltaToYType
|
|
*/
|
|
|
|
/**
|
|
* @param {YType<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
|
|
}
|
|
|
|
/**
|
|
* @param {YType} type
|
|
* @return {Array<any>}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeListToArray = type => {
|
|
type.doc ?? warnPrematureAccess()
|
|
const cs = []
|
|
let n = type._start
|
|
while (n !== null) {
|
|
if (n.countable && !n.deleted) {
|
|
const c = n.content.getContent()
|
|
for (let i = 0; i < c.length; i++) {
|
|
cs.push(c[i])
|
|
}
|
|
}
|
|
n = n.right
|
|
}
|
|
return cs
|
|
}
|
|
|
|
/**
|
|
* @param {YType<any>} type
|
|
* @param {Snapshot} snapshot
|
|
* @return {Array<any>}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeListToArraySnapshot = (type, snapshot) => {
|
|
const cs = []
|
|
let n = type._start
|
|
while (n !== null) {
|
|
if (n.countable && isVisible(n, snapshot)) {
|
|
const c = n.content.getContent()
|
|
for (let i = 0; i < c.length; i++) {
|
|
cs.push(c[i])
|
|
}
|
|
}
|
|
n = n.right
|
|
}
|
|
return cs
|
|
}
|
|
|
|
/**
|
|
* Executes a provided function on once on every element of this YArray.
|
|
*
|
|
* @param {YType<any>} type
|
|
* @param {function(any,number,any):void} f A function to execute on every element of this YArray.
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeListForEach = (type, f) => {
|
|
let index = 0
|
|
let n = type._start
|
|
type.doc ?? warnPrematureAccess()
|
|
while (n !== null) {
|
|
if (n.countable && !n.deleted) {
|
|
const c = n.content.getContent()
|
|
for (let i = 0; i < c.length; i++) {
|
|
f(c[i], index++, type)
|
|
}
|
|
}
|
|
n = n.right
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @template C,R
|
|
* @param {YType<any>} type
|
|
* @param {function(C,number,YType<any>):R} f
|
|
* @return {Array<R>}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeListMap = (type, f) => {
|
|
/**
|
|
* @type {Array<any>}
|
|
*/
|
|
const result = []
|
|
typeListForEach(type, (c, i) => {
|
|
result.push(f(c, i, type))
|
|
})
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* @param {YType} type
|
|
* @return {IterableIterator<any>}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeListCreateIterator = type => {
|
|
let n = type._start
|
|
/**
|
|
* @type {Array<any>|null}
|
|
*/
|
|
let currentContent = null
|
|
let currentContentIndex = 0
|
|
return {
|
|
[Symbol.iterator] () {
|
|
return this
|
|
},
|
|
next: () => {
|
|
// find some content
|
|
if (currentContent === null) {
|
|
while (n !== null && n.deleted) {
|
|
n = n.right
|
|
}
|
|
// check if we reached the end, no need to check currentContent, because it does not exist
|
|
if (n === null) {
|
|
return {
|
|
done: true,
|
|
value: undefined
|
|
}
|
|
}
|
|
// we found n, so we can set currentContent
|
|
currentContent = n.content.getContent()
|
|
currentContentIndex = 0
|
|
n = n.right // we used the content of n, now iterate to next
|
|
}
|
|
const value = currentContent[currentContentIndex++]
|
|
// check if we need to empty currentContent
|
|
if (currentContent.length <= currentContentIndex) {
|
|
currentContent = null
|
|
}
|
|
return {
|
|
done: false,
|
|
value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @todo remove / inline this
|
|
*
|
|
* @param {YType} type
|
|
* @param {number} index
|
|
* @return {any}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeListGet = (type, index) => {
|
|
type.doc ?? warnPrematureAccess()
|
|
const marker = findMarker(type, index)
|
|
let n = type._start
|
|
if (marker !== null) {
|
|
n = marker.p
|
|
index -= marker.index
|
|
}
|
|
for (; n !== null; n = n.right) {
|
|
if (!n.deleted && n.countable) {
|
|
if (index < n.length) {
|
|
return n.content.getContent()[index]
|
|
}
|
|
index -= n.length
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Transaction} transaction
|
|
* @param {YType} parent
|
|
* @param {Item?} referenceItem
|
|
* @param {Array<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, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent))
|
|
left.integrate(transaction, 0)
|
|
jsonContent = []
|
|
}
|
|
}
|
|
content.forEach(c => {
|
|
if (c === null) {
|
|
jsonContent.push(c)
|
|
} else {
|
|
switch (c.constructor) {
|
|
case Number:
|
|
case Object:
|
|
case undefined:
|
|
case Boolean:
|
|
case Array:
|
|
case String:
|
|
case BigInt:
|
|
case Date:
|
|
jsonContent.push(c)
|
|
break
|
|
default:
|
|
packJsonContent()
|
|
switch (c.constructor) {
|
|
case Uint8Array:
|
|
case ArrayBuffer:
|
|
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
|
|
left.integrate(transaction, 0)
|
|
break
|
|
case Doc:
|
|
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentDoc(/** @type {Doc} */ (c)))
|
|
left.integrate(transaction, 0)
|
|
break
|
|
default:
|
|
if (c instanceof YType) {
|
|
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(/** @type {any} */ (c)))
|
|
left.integrate(transaction, 0)
|
|
} else {
|
|
throw new Error('Unexpected content type in insert operation')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
packJsonContent()
|
|
}
|
|
|
|
const lengthExceeded = () => error.create('Length exceeded!')
|
|
|
|
/**
|
|
* @param {Transaction} transaction
|
|
* @param {YType} parent
|
|
* @param {number} index
|
|
* @param {Array<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 {YType} 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 {YType} parent
|
|
* @param {number} index
|
|
* @param {number} length
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeListDelete = (transaction, parent, index, length) => {
|
|
if (length === 0) { return }
|
|
const startIndex = index
|
|
const startLength = length
|
|
const marker = findMarker(parent, index)
|
|
let n = parent._start
|
|
if (marker !== null) {
|
|
n = marker.p
|
|
index -= marker.index
|
|
}
|
|
// compute the first item to be deleted
|
|
for (; n !== null && index > 0; n = n.right) {
|
|
if (!n.deleted && n.countable) {
|
|
if (index < n.length) {
|
|
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
|
|
}
|
|
index -= n.length
|
|
}
|
|
}
|
|
// delete all items until done
|
|
while (length > 0 && n !== null) {
|
|
if (!n.deleted) {
|
|
if (length < n.length) {
|
|
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length))
|
|
}
|
|
n.delete(transaction)
|
|
length -= n.length
|
|
}
|
|
n = n.right
|
|
}
|
|
if (length > 0) {
|
|
throw lengthExceeded()
|
|
}
|
|
if (parent._searchMarker) {
|
|
updateMarkerChanges(parent._searchMarker, startIndex, -startLength + length /* in case we remove the above exception */)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @todo inline this code
|
|
*
|
|
* @param {Transaction} transaction
|
|
* @param {YType} parent
|
|
* @param {string} key
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeMapDelete = (transaction, parent, key) => {
|
|
const c = parent._map.get(key)
|
|
if (c !== undefined) {
|
|
c.delete(transaction)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Transaction} transaction
|
|
* @param {YType} parent
|
|
* @param {string} key
|
|
* @param {YValue} value
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeMapSet = (transaction, parent, key, value) => {
|
|
const left = parent._map.get(key) || null
|
|
const doc = transaction.doc
|
|
const ownClientId = doc.clientID
|
|
let content
|
|
if (value == null) {
|
|
content = new ContentAny([value])
|
|
} else {
|
|
switch (value.constructor) {
|
|
case Number:
|
|
case Object:
|
|
case Boolean:
|
|
case Array:
|
|
case String:
|
|
case Date:
|
|
case BigInt:
|
|
content = new ContentAny([value])
|
|
break
|
|
case Uint8Array:
|
|
content = new ContentBinary(/** @type {Uint8Array} */ (value))
|
|
break
|
|
case Doc:
|
|
content = new ContentDoc(/** @type {Doc} */ (value))
|
|
break
|
|
default:
|
|
if (value instanceof YType) {
|
|
content = new ContentType(/** @type {any} */ (value))
|
|
} else {
|
|
throw new Error('Unexpected content type')
|
|
}
|
|
}
|
|
}
|
|
new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0)
|
|
}
|
|
|
|
/**
|
|
* @param {YType<any>} parent
|
|
* @param {string} key
|
|
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|YType<any>|undefined}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeMapGet = (parent, key) => {
|
|
parent.doc ?? warnPrematureAccess()
|
|
const val = parent._map.get(key)
|
|
return val !== undefined && !val.deleted ? val.content.getContent()[val.length - 1] : undefined
|
|
}
|
|
|
|
/**
|
|
* @param {YType<any>} parent
|
|
* @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|YType<any>|undefined>}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeMapGetAll = (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 {YType} parent
|
|
* @param {Set<string|null>?} attrsToRender
|
|
* @param {import('./internals.js').AbstractAttributionManager} am
|
|
* @param {boolean} deep
|
|
* @param {Set<YType>|Map<YType,any>|null} [modified] - set of types that should be rendered as modified children
|
|
* @param {import('./utils/IdSet.js').IdSet?} [deletedItems]
|
|
* @param {import('./utils/IdSet.js').IdSet?} [itemsToRender]
|
|
* @param {any} [opts]
|
|
* @param {any} [optsAll]
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeMapGetDelta = (d, parent, attrsToRender, am, deep, modified, deletedItems, itemsToRender, opts, optsAll) => {
|
|
// @todo support modified ops!
|
|
/**
|
|
* @param {Item} item
|
|
* @param {string} key
|
|
*/
|
|
const renderAttrs = (item, key) => {
|
|
/**
|
|
* @type {Array<import('./internals.js').AttributedContent<any>>}
|
|
*/
|
|
const cs = []
|
|
am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1)
|
|
const { deleted, attrs, content } = cs[cs.length - 1]
|
|
const attribution = createAttributionFromAttributionItems(attrs, deleted)
|
|
let c = array.last(content.getContent())
|
|
if (deleted) {
|
|
if (itemsToRender == null || itemsToRender.hasId(item.lastId)) {
|
|
d.deleteAttr(key, attribution, c)
|
|
}
|
|
} else if (deep && c instanceof YType && modified?.has(c)) {
|
|
d.modifyAttr(key, c.getContent(am, opts))
|
|
} else {
|
|
// find prev content
|
|
let prevContentItem = item
|
|
// this algorithm is problematic. should check all previous content using am.readcontent
|
|
for (; prevContentItem.left !== null && deletedItems?.hasId(prevContentItem.left.lastId); prevContentItem = prevContentItem.left) {
|
|
// nop
|
|
}
|
|
const prevValue = (prevContentItem !== item && itemsToRender?.hasId(prevContentItem.lastId)) ? array.last(prevContentItem.content.getContent()) : undefined
|
|
if (deep && c instanceof YType) {
|
|
c = /** @type {any} */(c).getContent(am, optsAll)
|
|
}
|
|
d.setAttr(key, c, attribution, prevValue)
|
|
}
|
|
}
|
|
if (attrsToRender == null) {
|
|
parent._map.forEach(renderAttrs)
|
|
} else {
|
|
attrsToRender.forEach(key => key != null && renderAttrs(/** @type {Item} */ (parent._map.get(key)), key))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {YType<any>} parent
|
|
* @param {string} key
|
|
* @return {boolean}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeMapHas = (parent, key) => {
|
|
parent.doc ?? warnPrematureAccess()
|
|
const val = parent._map.get(key)
|
|
return val !== undefined && !val.deleted
|
|
}
|
|
|
|
/**
|
|
* @param {YType<any>} parent
|
|
* @param {string} key
|
|
* @param {Snapshot} snapshot
|
|
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|YType<any>|undefined}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeMapGetSnapshot = (parent, key, snapshot) => {
|
|
let v = parent._map.get(key) || null
|
|
while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) {
|
|
v = v.left
|
|
}
|
|
return v !== null && isVisible(v, snapshot) ? v.content.getContent()[v.length - 1] : undefined
|
|
}
|
|
|
|
/**
|
|
* @param {YType<any>} parent
|
|
* @param {Snapshot} snapshot
|
|
* @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|YType<any>|undefined>}
|
|
*
|
|
* @private
|
|
* @function
|
|
*/
|
|
export const typeMapGetAllSnapshot = (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 {YType<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)
|
|
}
|