snapshot of https://github.com/yjs/yjs.git @ 96c96e1fcb1ef6ce866d5264b3f97f7f77b11f64 (2026-09-15)
This commit is contained in:
66
src/index.js
Normal file
66
src/index.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
/** eslint-env browser */
|
||||
|
||||
// Order matters: follows internals.js ordering to avoid circular dependency issues
|
||||
|
||||
export { IdSet, equalIdSets, createDeleteSetFromStructStore, createInsertSetFromStructStore, diffIdSet, createIdSet, mergeIdSets, insertIntoIdSet, iterateStructsByIdSet, gcIdSet, readIdSet, writeIdSet, encodeIdSet, decodeIdSet, IdMap, createIdMap, createContentAttribute, ContentAttribute, diffIdMap, encodeIdMap, createIdMapFromIdSet, createIdSetFromIdMap, insertIntoIdMap, mergeIdMaps, readIdMap, writeIdMap, decodeIdMap, filterIdMap, intersectSets, intersectMaps, $idSet, $idMapAny, $idMap } from './utils/ids.js'
|
||||
export { Doc } from './utils/Doc.js'
|
||||
export { $doc } from './utils/schemas.js'
|
||||
export { UpdateDecoderV1, UpdateDecoderV2 } from './utils/UpdateDecoder.js'
|
||||
export { UpdateEncoderV1, UpdateEncoderV2 } from './utils/UpdateEncoder.js'
|
||||
export { applyUpdate, applyUpdateV2, readUpdate, readUpdateV2, encodeStateAsUpdate, encodeStateAsUpdateV2, encodeStateVector, decodeStateVector, diffUpdate, diffUpdateV2, mergeUpdates, mergeUpdatesV2, createDocFromUpdate, createDocFromUpdateV2, cloneDoc } from './utils/encoding.js'
|
||||
export { ID, createID, compareIDs, findRootTypeKey } from './utils/ID.js'
|
||||
export { isParentOf } from './utils/isParentOf.js'
|
||||
export { logNode } from './utils/logging.js'
|
||||
export { createRelativePositionFromTypeIndex, createRelativePositionFromJSON, createAbsolutePositionFromRelativePosition, compareRelativePositions, AbsolutePosition, RelativePosition, relativePositionToJSON, encodeRelativePosition, decodeRelativePosition } from './utils/RelativePosition.js'
|
||||
export { Snapshot, createSnapshot, snapshot, emptySnapshot, createDocFromSnapshot, decodeSnapshot, encodeSnapshot, decodeSnapshotV2, encodeSnapshotV2, equalSnapshots, snapshotContainsUpdate } from './utils/Snapshot.js'
|
||||
export { findIndexSS, getItemCleanStart, getItemCleanEnd, tryGc } from './utils/transaction-helpers.js'
|
||||
export { Transaction, transact, cleanupYTextFormatting } from './utils/Transaction.js'
|
||||
export { UndoManager, undoContentIds } from './utils/UndoManager.js'
|
||||
export { logUpdate, logUpdateV2, decodeUpdate, decodeUpdateV2, encodeStateVectorFromUpdate, encodeStateVectorFromUpdateV2, convertUpdateFormatV1ToV2, convertUpdateFormatV2ToV1, obfuscateUpdate, obfuscateUpdateV2, createContentIdsFromUpdate, createContentIdsFromUpdateV2, intersectUpdateWithContentIds, intersectUpdateWithContentIdsV2 } from './utils/updates.js'
|
||||
export { YEvent, getPathTo } from './utils/YEvent.js'
|
||||
export { AttributionsRenderer, createAttributionsRenderer, AbstractRenderer, createDiffRenderer, DiffRenderer, createSnapshotRenderer, SnapshotRenderer, $renderer } from './utils/Renderer.js'
|
||||
export { diffDocsToDelta } from './utils/delta-helpers.js'
|
||||
export { createRelativePositionsFromDeltaPositions, createDeltaPositionsFromRelativePositions, createRelativePositionFromDeltaPosition, createDeltaPositionFromRelativePosition } from './utils/position-helpers.js'
|
||||
export { YNode as Node, getNodeChildren, nodeMapGetSnapshot, nodeMapGetAllSnapshot, $node, $nodeAny } from './ynode.js'
|
||||
export { AbstractStruct } from './structs/AbstractStruct.js'
|
||||
export { GC } from './structs/GC.js'
|
||||
export { Item, ContentBinary, ContentDeleted, ContentDoc, ContentEmbed, ContentFormat, ContentJSON, ContentAny, ContentString, ContentType } from './structs/Item.js'
|
||||
export { Skip } from './structs/Skip.js'
|
||||
|
||||
export * from './utils/meta.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('./utils/ids.js').ContentIds} ContentIds
|
||||
*/
|
||||
/**
|
||||
* @typedef {import('./utils/ids.js').ContentMap} ContentMap
|
||||
*/
|
||||
|
||||
const glo = /** @type {any} */ (typeof globalThis !== 'undefined'
|
||||
? globalThis
|
||||
: typeof window !== 'undefined'
|
||||
? window
|
||||
// @ts-ignore
|
||||
: typeof global !== 'undefined' ? global : {})
|
||||
|
||||
const importIdentifier = '__ $YJS14$ __'
|
||||
|
||||
if (glo[importIdentifier] === true) {
|
||||
/**
|
||||
* Dear reader of this message. Please take this seriously.
|
||||
*
|
||||
* If you see this message, make sure that you only import one version of Yjs. In many cases,
|
||||
* your package manager installs two versions of Yjs that are used by different packages within your project.
|
||||
* Another reason for this message is that some parts of your project use the commonjs version of Yjs
|
||||
* and others use the EcmaScript version of Yjs.
|
||||
*
|
||||
* This often leads to issues that are hard to debug. We often need to perform constructor checks,
|
||||
* e.g. `struct instanceof GC`. If you imported different versions of Yjs, it is impossible for us to
|
||||
* do the constructor checks anymore - which might break the CRDT algorithm.
|
||||
*
|
||||
* https://github.com/yjs/yjs/issues/438
|
||||
*/
|
||||
console.error('Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438')
|
||||
}
|
||||
glo[importIdentifier] = true
|
||||
64
src/structs/AbstractStruct.js
Normal file
64
src/structs/AbstractStruct.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as error from 'lib0/error'
|
||||
|
||||
export class AbstractStruct {
|
||||
/**
|
||||
* @param {ID} id
|
||||
* @param {number} length
|
||||
*/
|
||||
constructor (id, length) {
|
||||
this.id = id
|
||||
this.length = length
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
get deleted () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge this struct with the item to the right.
|
||||
* This method is already assuming that `this.id.clock + this.length === this.id.clock`.
|
||||
* Also this method does *not* remove right from StructStore!
|
||||
* @param {GC|Item|Skip} _right
|
||||
* @return {boolean} whether this merged with right
|
||||
*/
|
||||
mergeWith (_right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} _encoder The encoder to write data to.
|
||||
* @param {number} _offset
|
||||
* @param {number} _encodingRef
|
||||
*/
|
||||
write (_encoder, _offset, _encodingRef) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} _transaction
|
||||
* @param {number} _offset
|
||||
*/
|
||||
integrate (_transaction, _offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} _diff
|
||||
* @return {GC|Item|Skip}
|
||||
*/
|
||||
splice (_diff) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IdSet} idSet
|
||||
* @param {AbstractStruct} struct
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const addStructToIdSet = (idSet, struct) => idSet.add(struct.id.client, struct.id.clock, struct.length)
|
||||
75
src/structs/GC.js
Normal file
75
src/structs/GC.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { AbstractStruct, addStructToIdSet } from './AbstractStruct.js'
|
||||
import { createID } from '../utils/ID.js'
|
||||
|
||||
export const structGCRefNumber = 0
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class GC extends AbstractStruct {
|
||||
get deleted () {
|
||||
return true
|
||||
}
|
||||
|
||||
delete () {}
|
||||
|
||||
/**
|
||||
* @param {GC | Skip | Item} right
|
||||
* @return {boolean}
|
||||
*/
|
||||
mergeWith (right) {
|
||||
if (this.constructor !== right.constructor) {
|
||||
return false
|
||||
}
|
||||
this.length += right.length
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {number} offset - @todo remove offset parameter
|
||||
*/
|
||||
integrate (transaction, offset) {
|
||||
if (offset > 0) {
|
||||
this.id.clock += offset
|
||||
this.length -= offset
|
||||
}
|
||||
transaction.deleteSet.add(this.id.client, this.id.clock, this.length)
|
||||
addStructToIdSet(transaction.insertSet, this)
|
||||
transaction.doc.store.add(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {number} offset
|
||||
* @param {number} offsetEnd
|
||||
*/
|
||||
write (encoder, offset, offsetEnd) {
|
||||
encoder.writeInfo(structGCRefNumber)
|
||||
encoder.writeLen(this.length - offset - offsetEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* gc structs can't be spliced.
|
||||
*
|
||||
* If this feature is required in the future, then need to try to merge this struct after
|
||||
* transaction.
|
||||
*
|
||||
* @param {number} diff
|
||||
*/
|
||||
splice (diff) {
|
||||
const other = new GC(createID(this.id.client, this.id.clock + diff), this.length - diff)
|
||||
this.length = diff
|
||||
return other
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {0}
|
||||
*/
|
||||
GC.prototype.ref = structGCRefNumber
|
||||
|
||||
/**
|
||||
* @type {false}
|
||||
*/
|
||||
GC.prototype.isItem = false
|
||||
1517
src/structs/Item.js
Normal file
1517
src/structs/Item.js
Normal file
File diff suppressed because it is too large
Load Diff
72
src/structs/Skip.js
Normal file
72
src/structs/Skip.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as encoding from 'lib0/encoding'
|
||||
|
||||
import { AbstractStruct } from './AbstractStruct.js'
|
||||
import { createID } from '../utils/ID.js'
|
||||
|
||||
export const structSkipRefNumber = 10
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class Skip extends AbstractStruct {
|
||||
get deleted () {
|
||||
return false
|
||||
}
|
||||
|
||||
delete () {}
|
||||
|
||||
/**
|
||||
* @param {Skip} right
|
||||
* @return {boolean}
|
||||
*/
|
||||
mergeWith (right) {
|
||||
if (this.constructor !== right.constructor) {
|
||||
return false
|
||||
}
|
||||
this.length += right.length
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {number} offset
|
||||
*/
|
||||
integrate (transaction, offset) {
|
||||
if (offset > 0) {
|
||||
this.id.clock += offset
|
||||
this.length -= offset
|
||||
}
|
||||
const store = transaction.doc.store
|
||||
store.skips.add(this.id.client, this.id.clock, this.length)
|
||||
store.add(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
encoder.writeInfo(structSkipRefNumber)
|
||||
// write as VarUint because Skips can't make use of predictable length-encoding
|
||||
encoding.writeVarUint(encoder.restEncoder, this.length - offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} diff
|
||||
*/
|
||||
splice (diff) {
|
||||
const other = new Skip(createID(this.id.client, this.id.clock + diff), this.length - diff)
|
||||
this.length = diff
|
||||
return other
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {10}
|
||||
*/
|
||||
Skip.prototype.ref = structSkipRefNumber
|
||||
|
||||
/**
|
||||
* @type {false}
|
||||
*/
|
||||
Skip.prototype.isItem = false
|
||||
291
src/utils/BlockSet.js
Normal file
291
src/utils/BlockSet.js
Normal file
@@ -0,0 +1,291 @@
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as binary from 'lib0/binary'
|
||||
import * as map from 'lib0/map'
|
||||
import * as array from 'lib0/array'
|
||||
import * as math from 'lib0/math'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as number from 'lib0/number'
|
||||
|
||||
import { createID, ID } from './ID.js'
|
||||
import { Item } from '../structs/Item.js'
|
||||
import { readItemContent } from '../ynode.js'
|
||||
import { findIndexCleanStart } from './transaction-helpers.js'
|
||||
import { Skip } from '../structs/Skip.js'
|
||||
import { createIdSet, IdRange } from './ids.js'
|
||||
import { sliceStruct } from './updates.js'
|
||||
import { GC } from '../structs/GC.js'
|
||||
import { writeStructs } from './encoding-helpers.js'
|
||||
|
||||
/**
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder The decoder object to read data from.
|
||||
* @return {BlockSet}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readBlockSet = (decoder) => {
|
||||
const clientRefs = new BlockSet()
|
||||
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < numOfStateUpdates; i++) {
|
||||
const numberOfBlocks = decoding.readVarUint(decoder.restDecoder)
|
||||
/**
|
||||
* @type {Array<GC|Item|Skip>}
|
||||
*/
|
||||
const refs = new Array(numberOfBlocks)
|
||||
const client = decoder.readClient()
|
||||
let clock = decoding.readVarUint(decoder.restDecoder)
|
||||
clientRefs.clients.set(client, new BlockRange(refs))
|
||||
for (let i = 0; i < numberOfBlocks; i++) {
|
||||
const info = decoder.readInfo()
|
||||
switch (binary.BITS5 & info) {
|
||||
case 0: { // GC
|
||||
const len = decoder.readLen()
|
||||
refs[i] = new GC(createID(client, clock), len)
|
||||
clock += len
|
||||
break
|
||||
}
|
||||
case 10: { // Skip Block (nothing to apply)
|
||||
// @todo we could reduce the amount of checks by adding Skip block to clientRefs so we know that something is missing.
|
||||
const len = decoding.readVarUint(decoder.restDecoder)
|
||||
refs[i] = new Skip(createID(client, clock), len)
|
||||
clock += len
|
||||
break
|
||||
}
|
||||
default: { // Item with content
|
||||
/**
|
||||
* The optimized implementation doesn't use any variables because inlining variables is faster.
|
||||
* Below a non-optimized version is shown that implements the basic algorithm with
|
||||
* a few comments
|
||||
*/
|
||||
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||
// and we read the next string as parentYKey.
|
||||
// It indicates how we store/retrieve parent from `y.share`
|
||||
// @type {string|null}
|
||||
const block = new Item(
|
||||
createID(client, clock),
|
||||
null, // left
|
||||
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
|
||||
null, // right
|
||||
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
|
||||
cantCopyParentInfo ? (decoder.readParentInfo() ? decoder.readString() : decoder.readLeftID()) : null, // parent
|
||||
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
|
||||
readItemContent(decoder, info) // item content
|
||||
)
|
||||
refs[i] = block
|
||||
clock += block.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return clientRefs
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {BlockSet} blocks
|
||||
*/
|
||||
export const writeBlockSet = (encoder, blocks) => {
|
||||
// write # states that were updated
|
||||
encoding.writeVarUint(encoder.restEncoder, blocks.clients.size)
|
||||
// Write items with higher client ids first
|
||||
// This heavily improves the conflict algorithm.
|
||||
array.from(blocks.clients.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, blockrange]) => {
|
||||
writeStructs(encoder, blockrange.refs, client, [new IdRange(0, number.MAX_SAFE_INTEGER)])
|
||||
})
|
||||
}
|
||||
|
||||
class BlockRange {
|
||||
/**
|
||||
* @param {Array<Item|GC|Skip>} refs
|
||||
*/
|
||||
constructor (refs) {
|
||||
this.i = 0
|
||||
/**
|
||||
* @type {Array<Item | GC | Skip>}
|
||||
*/
|
||||
this.refs = refs
|
||||
}
|
||||
}
|
||||
|
||||
export class BlockSet {
|
||||
constructor () {
|
||||
/**
|
||||
* @type {Map<number, BlockRange>}
|
||||
*/
|
||||
this.clients = map.create()
|
||||
}
|
||||
|
||||
toIdSet () {
|
||||
const inserts = createIdSet()
|
||||
this.clients.forEach((ranges, clientid) => {
|
||||
let lastClock = 0
|
||||
let lastLen = 0
|
||||
ranges.refs.forEach(block => {
|
||||
if (block instanceof Skip) return
|
||||
if (lastClock + lastLen === block.id.clock) {
|
||||
// default case: extend prev entry
|
||||
lastLen += block.length
|
||||
} else {
|
||||
lastLen > 0 && inserts.add(clientid, lastClock, lastLen)
|
||||
lastClock = block.id.clock
|
||||
lastLen = block.length
|
||||
}
|
||||
})
|
||||
inserts.add(clientid, lastClock, lastLen)
|
||||
})
|
||||
return inserts
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove id-ranges from update - convert them to skip if applicable.
|
||||
*
|
||||
* @param {IdSet} exclude
|
||||
*/
|
||||
exclude (exclude) {
|
||||
const clientids = this.clients.size < exclude.clients.size ? this.clients.keys() : exclude.clients.keys()
|
||||
for (const client of clientids) {
|
||||
const range = exclude.clients.get(client)
|
||||
const structs = this.clients.get(client)?.refs
|
||||
if (range == null || structs == null) return
|
||||
const firstStruct = structs[0]
|
||||
const lastStruct = structs[structs.length - 1]
|
||||
const idranges = range.getIds()
|
||||
for (let i = 0; i < idranges.length; i++) {
|
||||
const range = idranges[i]
|
||||
let startIndex = 0
|
||||
if (range.clock >= lastStruct.id.clock + lastStruct.length) continue
|
||||
if (range.clock > firstStruct.id.clock) {
|
||||
startIndex = findIndexCleanStart(null, structs, range.clock)
|
||||
}
|
||||
let endIndex = structs.length // must be set here, after structs is modified
|
||||
if (range.clock + range.len <= firstStruct.id.clock) continue
|
||||
if (range.clock + range.len < lastStruct.id.clock + lastStruct.length) {
|
||||
endIndex = findIndexCleanStart(null, structs, range.clock + range.len)
|
||||
}
|
||||
if (startIndex < endIndex) {
|
||||
structs[startIndex] = new Skip(new ID(client, range.clock), range.len)
|
||||
const d = endIndex - startIndex
|
||||
if (d > 1) {
|
||||
structs.splice(startIndex + 1, d - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BlockSet} inserts
|
||||
*/
|
||||
insertInto (inserts) {
|
||||
inserts.clients.forEach((newranges, clientid) => {
|
||||
const ranges = this.clients.get(clientid)
|
||||
if (ranges == null) {
|
||||
this.clients.set(clientid, newranges)
|
||||
} else {
|
||||
const localIsLeft = ranges.refs[0].id.clock < newranges.refs[0].id.clock
|
||||
const leftRanges = (localIsLeft ? ranges : newranges).refs
|
||||
const rightRanges = (localIsLeft ? newranges : ranges).refs
|
||||
const lastBlockLeft = array.last(leftRanges)
|
||||
const firstBlockRight = rightRanges[0]
|
||||
const gapSize = firstBlockRight.id.clock - lastBlockLeft.id.clock - lastBlockLeft.length
|
||||
if (gapSize >= 0) {
|
||||
// we can do a simple efficient merge
|
||||
if (gapSize > 0) {
|
||||
leftRanges.push(new Skip(new ID(clientid, lastBlockLeft.id.clock + lastBlockLeft.length), gapSize))
|
||||
}
|
||||
for (let i = 0; i < rightRanges.length; i++) {
|
||||
leftRanges.push(rightRanges[i])
|
||||
}
|
||||
ranges.refs = leftRanges
|
||||
} else {
|
||||
// requires more computation because we need to filter duplicates
|
||||
/**
|
||||
* @type {Array<GC|Item|Skip>}
|
||||
*/
|
||||
const result = []
|
||||
let nextExpectedClock = leftRanges[0].id.clock
|
||||
/**
|
||||
* @param {Item|GC|Skip} block
|
||||
*/
|
||||
const addToResult = block => {
|
||||
result.push(block)
|
||||
nextExpectedClock = block.id.clock + block.length
|
||||
}
|
||||
let li = 0
|
||||
let ri = 0
|
||||
/**
|
||||
* @type {Item|GC|Skip|undefined}
|
||||
*/
|
||||
let lblock = leftRanges[li]
|
||||
/**
|
||||
* @type {Item|GC|Skip|undefined}
|
||||
*/
|
||||
let rblock = rightRanges[ri]
|
||||
const applyLeft = () => {
|
||||
if (lblock === undefined) return
|
||||
// first try to consume left
|
||||
// left: filter skips and known ops
|
||||
while (lblock !== undefined && (lblock.constructor === Skip || lblock.id.clock + lblock.length <= nextExpectedClock)) {
|
||||
lblock = leftRanges[++li]
|
||||
}
|
||||
// left: trim first op
|
||||
if (lblock !== undefined && lblock.id.clock < nextExpectedClock && lblock.id.clock + lblock.length > nextExpectedClock) {
|
||||
lblock = sliceStruct(lblock, lblock.id.clock + lblock.length - nextExpectedClock)
|
||||
}
|
||||
// left: add to result
|
||||
while (lblock !== undefined && lblock.id.clock === nextExpectedClock && lblock.constructor !== Skip) {
|
||||
addToResult(lblock)
|
||||
lblock = leftRanges[++li]
|
||||
}
|
||||
}
|
||||
const applyRight = () => {
|
||||
// right: filter skips and known ops
|
||||
while (rblock !== undefined && (rblock.constructor === Skip || rblock.id.clock + rblock.length <= nextExpectedClock)) {
|
||||
rblock = rightRanges[++ri]
|
||||
}
|
||||
// right: trim first op
|
||||
if (rblock !== undefined && rblock.id.clock < nextExpectedClock && rblock.id.clock + rblock.length > nextExpectedClock) {
|
||||
rblock = sliceStruct(rblock, rblock.id.clock + rblock.length - nextExpectedClock)
|
||||
}
|
||||
// right: add to result
|
||||
while (rblock !== undefined && rblock.id.clock === nextExpectedClock && rblock.constructor !== Skip) {
|
||||
addToResult(rblock)
|
||||
rblock = rightRanges[++ri]
|
||||
}
|
||||
}
|
||||
for (; li < leftRanges.length && ri < rightRanges.length;) {
|
||||
applyLeft()
|
||||
applyRight()
|
||||
// add skip if necessary
|
||||
const minNextClock = math.min(lblock?.id.clock || 0, rblock?.id.clock || 0)
|
||||
const gapSize = minNextClock - nextExpectedClock
|
||||
if (gapSize > 0) {
|
||||
addToResult(new Skip(new ID(clientid, nextExpectedClock), gapSize))
|
||||
}
|
||||
}
|
||||
while (li < leftRanges.length) {
|
||||
applyLeft()
|
||||
if (lblock !== undefined) {
|
||||
const gapSize = lblock.id.clock - nextExpectedClock
|
||||
if (gapSize > 0) {
|
||||
addToResult(new Skip(new ID(clientid, nextExpectedClock), gapSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
while (ri < rightRanges.length) {
|
||||
applyRight()
|
||||
if (rblock !== undefined) {
|
||||
const gapSize = rblock.id.clock - nextExpectedClock
|
||||
if (gapSize > 0) {
|
||||
addToResult(new Skip(new ID(clientid, nextExpectedClock), gapSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
ranges.refs = result
|
||||
}
|
||||
}
|
||||
})
|
||||
inserts.clients.clear()
|
||||
}
|
||||
}
|
||||
255
src/utils/Doc.js
Normal file
255
src/utils/Doc.js
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* @module Y
|
||||
*/
|
||||
|
||||
import { ObservableV2 } from 'lib0/observable'
|
||||
import * as random from 'lib0/random'
|
||||
import * as map from 'lib0/map'
|
||||
import * as array from 'lib0/array'
|
||||
import * as promise from 'lib0/promise'
|
||||
|
||||
import { StructStore } from './StructStore.js'
|
||||
import { transact, generateNewClientId } from './Transaction.js'
|
||||
import { YNode } from '../ynode.js'
|
||||
import { $doc } from './schemas.js'
|
||||
|
||||
/**
|
||||
* @typedef {Object} DocOpts
|
||||
* @property {boolean} [DocOpts.gc=true] Disable garbage collection (default: gc=true)
|
||||
* @property {function(Item):boolean} [DocOpts.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
|
||||
* @property {string} [DocOpts.guid] Define a globally unique identifier for this document
|
||||
* @property {string | null} [DocOpts.collectionid] Associate this document with a collection. This only plays a role if your provider has a concept of collection.
|
||||
* @property {any} [DocOpts.meta] Any kind of meta information you want to associate with this document. If this is a subdocument, remote peers will store the meta information as well.
|
||||
* @property {boolean} [DocOpts.autoLoad] If a subdocument, automatically load document. If this is a subdocument, remote peers will load the document as well automatically.
|
||||
* @property {boolean} [DocOpts.shouldLoad] Whether the document should be synced by the provider now. This is toggled to true when you call ydoc.load()
|
||||
* @property {boolean} [DocOpts.isSuggestionDoc] Set to true if this document merely suggests
|
||||
* changes. If this flag is not set in a suggestion document, automatic formatting changes will be
|
||||
* displayed as suggestions, which might not be intended.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DocEvents
|
||||
* @property {function(Doc):void} DocEvents.destroy
|
||||
* @property {function(Doc):void} DocEvents.load
|
||||
* @property {function(boolean, Doc):void} DocEvents.sync
|
||||
* @property {function(Uint8Array<ArrayBuffer>, any, Doc, Transaction):void} DocEvents.update
|
||||
* @property {function(Uint8Array<ArrayBuffer>, any, Doc, Transaction):void} DocEvents.updateV2
|
||||
* @property {function(Doc):void} DocEvents.beforeAllTransactions
|
||||
* @property {function(Transaction, Doc):void} DocEvents.beforeTransaction
|
||||
* @property {function(Transaction, Doc):void} DocEvents.beforeObserverCalls
|
||||
* @property {function(Transaction, Doc):void} DocEvents.afterTransaction
|
||||
* @property {function(Transaction, Doc):void} DocEvents.afterTransactionCleanup
|
||||
* @property {function(Doc, Array<Transaction>):void} DocEvents.afterAllTransactions
|
||||
* @property {function({ loaded: Set<Doc>, added: Set<Doc>, removed: Set<Doc> }, Doc, Transaction):void} DocEvents.subdocs
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Yjs instance handles the state of shared data.
|
||||
* @extends ObservableV2<DocEvents>
|
||||
*/
|
||||
export class Doc extends ObservableV2 {
|
||||
/**
|
||||
* @param {DocOpts} opts configuration
|
||||
*/
|
||||
constructor ({ guid = random.uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false, shouldLoad = true, isSuggestionDoc = false } = {}) {
|
||||
super()
|
||||
this.gc = gc
|
||||
this.gcFilter = gcFilter
|
||||
this.clientID = generateNewClientId()
|
||||
this.guid = guid
|
||||
this.collectionid = collectionid
|
||||
this.isSuggestionDoc = isSuggestionDoc
|
||||
this.cleanupFormatting = !isSuggestionDoc
|
||||
/**
|
||||
* @type {Map<string, YNode>}
|
||||
*/
|
||||
this.share = new Map()
|
||||
this.store = new StructStore()
|
||||
/**
|
||||
* @type {Transaction | null}
|
||||
*/
|
||||
this._transaction = null
|
||||
/**
|
||||
* @type {Array<Transaction>}
|
||||
*/
|
||||
this._transactionCleanups = []
|
||||
/**
|
||||
* @type {Set<Doc>}
|
||||
*/
|
||||
this.subdocs = new Set()
|
||||
/**
|
||||
* If this document is a subdocument - a document integrated into another document - then _item is defined.
|
||||
* @type {Item?}
|
||||
*/
|
||||
this._item = null
|
||||
this.shouldLoad = shouldLoad
|
||||
this.autoLoad = autoLoad
|
||||
this.meta = meta
|
||||
/**
|
||||
* This is set to true when the persistence provider loaded the document from the database or when the `sync` event fires.
|
||||
* Note that not all providers implement this feature. Provider authors are encouraged to fire the `load` event when the doc content is loaded from the database.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.isLoaded = false
|
||||
/**
|
||||
* This is set to true when the connection provider has successfully synced with a backend.
|
||||
* Note that when using peer-to-peer providers this event may not provide very useful.
|
||||
* Also note that not all providers implement this feature. Provider authors are encouraged to fire
|
||||
* the `sync` event when the doc has been synced (with `true` as a parameter) or if connection is
|
||||
* lost (with false as a parameter).
|
||||
*/
|
||||
this.isSynced = false
|
||||
this.isDestroyed = false
|
||||
/**
|
||||
* Promise that resolves once the document has been loaded from a persistence provider.
|
||||
*/
|
||||
this.whenLoaded = promise.create(resolve => {
|
||||
this.on('load', () => {
|
||||
this.isLoaded = true
|
||||
resolve(this)
|
||||
})
|
||||
})
|
||||
const provideSyncedPromise = () => promise.create(resolve => {
|
||||
/**
|
||||
* @param {boolean} isSynced
|
||||
*/
|
||||
const eventHandler = (isSynced) => {
|
||||
if (isSynced === undefined || isSynced === true) {
|
||||
this.off('sync', eventHandler)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
this.on('sync', eventHandler)
|
||||
})
|
||||
this.on('sync', isSynced => {
|
||||
if (isSynced === false && this.isSynced) {
|
||||
this.whenSynced = provideSyncedPromise()
|
||||
}
|
||||
this.isSynced = isSynced === undefined || isSynced === true
|
||||
if (this.isSynced && !this.isLoaded) {
|
||||
this.emit('load', [this])
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Promise that resolves once the document has been synced with a backend.
|
||||
* This promise is recreated when the connection is lost.
|
||||
* Note the documentation about the `isSynced` property.
|
||||
*/
|
||||
this.whenSynced = provideSyncedPromise()
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the parent document that you request to load data into this subdocument (if it is a subdocument).
|
||||
*
|
||||
* `load()` might be used in the future to request any provider to load the most current data.
|
||||
*
|
||||
* It is safe to call `load()` multiple times.
|
||||
*/
|
||||
load () {
|
||||
const item = this._item
|
||||
if (item !== null && !this.shouldLoad) {
|
||||
transact(/** @type {any} */ (item.parent).doc, transaction => {
|
||||
transaction.subdocsLoaded.add(this)
|
||||
}, null, true)
|
||||
}
|
||||
this.shouldLoad = true
|
||||
}
|
||||
|
||||
getSubdocs () {
|
||||
return this.subdocs
|
||||
}
|
||||
|
||||
getSubdocGuids () {
|
||||
return new Set(array.from(this.subdocs).map(doc => doc.guid))
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes that happen inside of a transaction are bundled. This means that
|
||||
* the observer fires _after_ the transaction is finished and that all changes
|
||||
* that happened inside of the transaction are sent as one message to the
|
||||
* other peers.
|
||||
*
|
||||
* @template T
|
||||
* @param {function(Transaction):T} f The function that should be executed as a transaction
|
||||
* @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin
|
||||
* @param {boolean} [local]
|
||||
* @return T
|
||||
*/
|
||||
transact (f, origin = null, local = true) {
|
||||
return transact(this, f, origin, local)
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a shared data type.
|
||||
*
|
||||
* Multiple calls of `ydoc.get(name)` yield the same result
|
||||
* and do not overwrite each other. I.e.
|
||||
* `ydoc.get(name) === ydoc.get(name)`
|
||||
*
|
||||
* After this method is called, the type is also available on `ydoc.share.get(name)`.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {string?} name Type-name
|
||||
*
|
||||
* @return {YNode}
|
||||
*/
|
||||
get (key = '', name = null) {
|
||||
return map.setIfUndefined(this.share, key, () => {
|
||||
const t = new YNode(name)
|
||||
t._integrate(this, null)
|
||||
return t
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the entire document into a js object, recursively traversing each yjs type
|
||||
* Doesn't log types that have not been defined (using ydoc.getType(..)).
|
||||
*
|
||||
* @deprecated Do not use this method and rather call toJSON directly on the shared types.
|
||||
*
|
||||
* @return {Object<string, any>}
|
||||
*/
|
||||
toJSON () {
|
||||
/**
|
||||
* @type {Object<string, any>}
|
||||
*/
|
||||
const doc = {}
|
||||
this.share.forEach((value, key) => {
|
||||
doc[key] = value.toJSON()
|
||||
})
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `destroy` event and unregister all event handlers.
|
||||
*/
|
||||
destroy () {
|
||||
this.isDestroyed = true
|
||||
// Tear down the top-level shared types as RDTs (emits their `'destroy'` event so any bindings
|
||||
// unsubscribe). Only top-level types are destroyed here; nested child types are not recursed.
|
||||
this.share.forEach(type => type.destroy())
|
||||
array.from(this.subdocs).forEach(subdoc => subdoc.destroy())
|
||||
const item = this._item
|
||||
if (item !== null) {
|
||||
this._item = null
|
||||
const content = /** @type {ContentDoc} */ (item.content)
|
||||
/**
|
||||
* new content doc which replaces the new one
|
||||
*/
|
||||
const contentDoc = new Doc({ guid: this.guid, ...content.opts, shouldLoad: false })
|
||||
content.doc = contentDoc
|
||||
contentDoc._item = item
|
||||
transact(/** @type {any} */ (item).parent.doc, transaction => {
|
||||
if (!item.deleted) {
|
||||
transaction.subdocsAdded.add(contentDoc)
|
||||
}
|
||||
transaction.subdocsRemoved.add(this)
|
||||
}, null, true)
|
||||
}
|
||||
this.emit('destroy', [this])
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
Doc.prototype.$type = $doc
|
||||
87
src/utils/EventHandler.js
Normal file
87
src/utils/EventHandler.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as f from 'lib0/function'
|
||||
|
||||
/**
|
||||
* General event handler implementation.
|
||||
*
|
||||
* @template ARG0, ARG1
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export class EventHandler {
|
||||
constructor () {
|
||||
/**
|
||||
* @type {Array<function(ARG0, ARG1):void>}
|
||||
*/
|
||||
this.l = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template ARG0,ARG1
|
||||
* @returns {EventHandler<ARG0,ARG1>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const createEventHandler = () => new EventHandler()
|
||||
|
||||
/**
|
||||
* Adds an event listener that is called when
|
||||
* {@link EventHandler#callEventListeners} is called.
|
||||
*
|
||||
* @template ARG0,ARG1
|
||||
* @param {EventHandler<ARG0,ARG1>} eventHandler
|
||||
* @param {function(ARG0,ARG1):void} f The event handler.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const addEventHandlerListener = (eventHandler, f) =>
|
||||
eventHandler.l.push(f)
|
||||
|
||||
/**
|
||||
* Removes an event listener.
|
||||
*
|
||||
* @template ARG0,ARG1
|
||||
* @param {EventHandler<ARG0,ARG1>} eventHandler
|
||||
* @param {function(ARG0,ARG1):void} f The event handler that was added with
|
||||
* {@link EventHandler#addEventListener}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const removeEventHandlerListener = (eventHandler, f) => {
|
||||
const l = eventHandler.l
|
||||
const len = l.length
|
||||
eventHandler.l = l.filter(g => f !== g)
|
||||
if (len === eventHandler.l.length) {
|
||||
console.error('[yjs] Tried to remove event handler that doesn\'t exist.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all event listeners.
|
||||
* @template ARG0,ARG1
|
||||
* @param {EventHandler<ARG0,ARG1>} eventHandler
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const removeAllEventHandlerListeners = eventHandler => {
|
||||
eventHandler.l.length = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Call all event listeners that were added via
|
||||
* {@link EventHandler#addEventListener}.
|
||||
*
|
||||
* @template ARG0,ARG1
|
||||
* @param {EventHandler<ARG0,ARG1>} eventHandler
|
||||
* @param {ARG0} arg0
|
||||
* @param {ARG1} arg1
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const callEventHandlerListeners = (eventHandler, arg0, arg1) =>
|
||||
f.callAll(eventHandler.l, [arg0, arg1])
|
||||
87
src/utils/ID.js
Normal file
87
src/utils/ID.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as error from 'lib0/error'
|
||||
|
||||
export class ID {
|
||||
/**
|
||||
* @param {number} client client id
|
||||
* @param {number} clock unique per client id, continuous number
|
||||
*/
|
||||
constructor (client, clock) {
|
||||
/**
|
||||
* Client id
|
||||
* @type {number}
|
||||
*/
|
||||
this.client = client
|
||||
/**
|
||||
* unique per client id, continuous number
|
||||
* @type {number}
|
||||
*/
|
||||
this.clock = clock
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID | null} a
|
||||
* @param {ID | null} b
|
||||
* @return {boolean}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const compareIDs = (a, b) => a === b || (a !== null && b !== null && a.client === b.client && a.clock === b.clock)
|
||||
|
||||
/**
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const createID = (client, clock) => new ID(client, clock)
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {ID} id
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const writeID = (encoder, id) => {
|
||||
encoding.writeVarUint(encoder, id.client)
|
||||
encoding.writeVarUint(encoder, id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ID.
|
||||
* * If first varUint read is 0xFFFFFF a RootID is returned.
|
||||
* * Otherwise an ID is returned
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {ID}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readID = decoder =>
|
||||
createID(decoding.readVarUint(decoder), decoding.readVarUint(decoder))
|
||||
|
||||
/**
|
||||
* The top types are mapped from y.share.get(keyname) => type.
|
||||
* `type` does not store any information about the `keyname`.
|
||||
* This function finds the correct `keyname` for `type` and throws otherwise.
|
||||
*
|
||||
* @param {YNode<any>} type
|
||||
* @return {string}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const findRootTypeKey = type => {
|
||||
// @ts-ignore _y must be defined, otherwise unexpected case
|
||||
for (const [key, value] of type.doc.share.entries()) {
|
||||
if (value === type) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
344
src/utils/RelativePosition.js
Normal file
344
src/utils/RelativePosition.js
Normal file
@@ -0,0 +1,344 @@
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as error from 'lib0/error'
|
||||
|
||||
import { Item, followRedone, ContentType } from '../structs/Item.js'
|
||||
import { writeID, readID, compareIDs, findRootTypeKey, createID } from './ID.js'
|
||||
import { rendererContentLength } from './renderer-helpers.js'
|
||||
|
||||
/**
|
||||
* A relative position is based on the Yjs model and is not affected by document changes.
|
||||
* E.g. If you place a relative position before a certain character, it will always point to this character.
|
||||
* If you place a relative position at the end of a type, it will always point to the end of the type.
|
||||
*
|
||||
* A numeric position is often unsuited for user selections, because it does not change when content is inserted
|
||||
* before or after.
|
||||
*
|
||||
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the relative position.
|
||||
*
|
||||
* One of the properties must be defined.
|
||||
*
|
||||
* @example
|
||||
* // Current cursor position is at position 10
|
||||
* const relativePosition = createRelativePositionFromIndex(yText, 10)
|
||||
* // modify yText
|
||||
* yText.insert(0, 'abc')
|
||||
* yText.delete(3, 10)
|
||||
* // Compute the cursor position
|
||||
* const absolutePosition = createAbsolutePositionFromRelativePosition(y, relativePosition)
|
||||
* absolutePosition.type === yText // => true
|
||||
* console.log('cursor location is ' + absolutePosition.index) // => cursor location is 3
|
||||
*
|
||||
*/
|
||||
export class RelativePosition {
|
||||
/**
|
||||
* @param {ID|null} type
|
||||
* @param {string|null} tname
|
||||
* @param {ID|null} item
|
||||
* @param {number} assoc
|
||||
*/
|
||||
constructor (type, tname, item, assoc = 0) {
|
||||
/**
|
||||
* @type {ID|null}
|
||||
*/
|
||||
this.type = type
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
this.tname = tname
|
||||
/**
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.item = item
|
||||
/**
|
||||
* A relative position is associated to a specific character. By default
|
||||
* assoc >= 0, the relative position is associated to the character
|
||||
* after the meant position.
|
||||
* I.e. position 1 in 'ab' is associated to character 'b'.
|
||||
*
|
||||
* If assoc < 0, then the relative position is associated to the character
|
||||
* before the meant position.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
this.assoc = assoc
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelativePosition} rpos
|
||||
* @return {any}
|
||||
*/
|
||||
export const relativePositionToJSON = rpos => {
|
||||
const json = {}
|
||||
if (rpos.type) {
|
||||
json.type = rpos.type
|
||||
}
|
||||
if (rpos.tname) {
|
||||
json.tname = rpos.tname
|
||||
}
|
||||
if (rpos.item) {
|
||||
json.item = rpos.item
|
||||
}
|
||||
if (rpos.assoc != null) {
|
||||
json.assoc = rpos.assoc
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @return {RelativePosition}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createRelativePositionFromJSON = json => new RelativePosition(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname ?? null, json.item == null ? null : createID(json.item.client, json.item.clock), json.assoc == null ? 0 : json.assoc)
|
||||
|
||||
export class AbsolutePosition {
|
||||
/**
|
||||
* @param {YNode<any>} type
|
||||
* @param {number} index
|
||||
* @param {number} [assoc]
|
||||
*/
|
||||
constructor (type, index, assoc = 0) {
|
||||
/**
|
||||
* @type {YNode<any>}
|
||||
*/
|
||||
this.type = type
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.index = index
|
||||
this.assoc = assoc
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {YNode<any>} type
|
||||
* @param {number} index
|
||||
* @param {number} [assoc]
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createAbsolutePosition = (type, index, assoc = 0) => new AbsolutePosition(type, index, assoc)
|
||||
|
||||
/**
|
||||
* @param {YNode<any>} type
|
||||
* @param {ID|null} item
|
||||
* @param {number} [assoc]
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createRelativePosition = (type, item, assoc) => {
|
||||
let typeid = null
|
||||
let tname = null
|
||||
if (type._item === null) {
|
||||
tname = findRootTypeKey(type)
|
||||
} else {
|
||||
typeid = createID(type._item.id.client, type._item.id.clock)
|
||||
}
|
||||
return new RelativePosition(typeid, tname, item, assoc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a relativePosition based on a absolute position.
|
||||
*
|
||||
* @param {YNode} type The base type (e.g. YText or YArray).
|
||||
* @param {number} index The absolute position.
|
||||
* @param {number} [assoc]
|
||||
* @param {import('../utils/Renderer.js').AbstractRenderer?} renderer
|
||||
* @return {RelativePosition}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createRelativePositionFromTypeIndex = (type, index, assoc = 0, renderer = null) => {
|
||||
let t = type._start
|
||||
if (assoc < 0) {
|
||||
// associated to the left character or the beginning of a type, increment index if possible.
|
||||
if (index === 0) {
|
||||
return createRelativePosition(type, null, assoc)
|
||||
}
|
||||
index--
|
||||
}
|
||||
while (t !== null) {
|
||||
const len = rendererContentLength(renderer, t)
|
||||
if (len > index) {
|
||||
// case 1: found position somewhere in the linked list
|
||||
return createRelativePosition(type, createID(t.id.client, t.id.clock + index), assoc)
|
||||
}
|
||||
index -= len
|
||||
if (t.right === null && assoc < 0) {
|
||||
// left-associated position, return last available id
|
||||
return createRelativePosition(type, t.lastId, assoc)
|
||||
}
|
||||
t = t.right
|
||||
}
|
||||
return createRelativePosition(type, null, assoc)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {RelativePosition} rpos
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const writeRelativePosition = (encoder, rpos) => {
|
||||
const { type, tname, item, assoc } = rpos
|
||||
if (item !== null) {
|
||||
encoding.writeVarUint(encoder, 0)
|
||||
writeID(encoder, item)
|
||||
} else if (tname !== null) {
|
||||
// case 2: found position at the end of the list and type is stored in y.share
|
||||
encoding.writeUint8(encoder, 1)
|
||||
encoding.writeVarString(encoder, tname)
|
||||
} else if (type !== null) {
|
||||
// case 3: found position at the end of the list and type is attached to an item
|
||||
encoding.writeUint8(encoder, 2)
|
||||
writeID(encoder, type)
|
||||
} else {
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
encoding.writeVarInt(encoder, assoc)
|
||||
return encoder
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelativePosition} rpos
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const encodeRelativePosition = rpos => {
|
||||
const encoder = encoding.createEncoder()
|
||||
writeRelativePosition(encoder, rpos)
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {RelativePosition}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readRelativePosition = decoder => {
|
||||
let type = null
|
||||
let tname = null
|
||||
let itemID = null
|
||||
switch (decoding.readVarUint(decoder)) {
|
||||
case 0:
|
||||
// case 1: found position somewhere in the linked list
|
||||
itemID = readID(decoder)
|
||||
break
|
||||
case 1:
|
||||
// case 2: found position at the end of the list and type is stored in y.share
|
||||
tname = decoding.readVarString(decoder)
|
||||
break
|
||||
case 2: {
|
||||
// case 3: found position at the end of the list and type is attached to an item
|
||||
type = readID(decoder)
|
||||
}
|
||||
}
|
||||
const assoc = decoding.hasContent(decoder) ? decoding.readVarInt(decoder) : 0
|
||||
return new RelativePosition(type, tname, itemID, assoc)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} uint8Array
|
||||
* @return {RelativePosition}
|
||||
*/
|
||||
export const decodeRelativePosition = uint8Array => readRelativePosition(decoding.createDecoder(uint8Array))
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
* @param {ID} id
|
||||
*/
|
||||
const getItemWithOffset = (store, id) => {
|
||||
const item = store.getItem(id)
|
||||
const diff = id.clock - item.id.clock
|
||||
return {
|
||||
item, diff
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a relative position to an absolute position.
|
||||
*
|
||||
* If you want to share the relative position with other users, you should set
|
||||
* `followUndoneDeletions` to false to get consistent results across all clients.
|
||||
*
|
||||
* When calculating the absolute position, we try to follow the "undone deletions". This yields
|
||||
* better results for the user who performed undo. However, only the user who performed the undo
|
||||
* will get the better results, the other users don't know which operations recreated a deleted
|
||||
* range of content. There is more information in this ticket: https://github.com/yjs/yjs/issues/638
|
||||
*
|
||||
* @param {RelativePosition} rpos
|
||||
* @param {Doc} doc
|
||||
* @param {boolean} followUndoneDeletions - whether to follow undone deletions - see https://github.com/yjs/yjs/issues/638
|
||||
* @param {import('../utils/Renderer.js').AbstractRenderer?} renderer
|
||||
* @return {AbsolutePosition|null}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createAbsolutePositionFromRelativePosition = (rpos, doc, followUndoneDeletions = true, renderer = null) => {
|
||||
const store = doc.store
|
||||
const rightID = rpos.item
|
||||
const typeID = rpos.type
|
||||
const tname = rpos.tname
|
||||
const assoc = rpos.assoc
|
||||
let type = null
|
||||
let index = 0
|
||||
if (rightID !== null) {
|
||||
if (store.getClock(rightID.client) <= rightID.clock) {
|
||||
return null
|
||||
}
|
||||
const res = followUndoneDeletions ? followRedone(store, rightID) : getItemWithOffset(store, rightID)
|
||||
const right = res.item
|
||||
if (!(right instanceof Item)) {
|
||||
return null
|
||||
}
|
||||
type = /** @type {YNode<any>} */ (right.parent)
|
||||
// an index into a deleted type is meaningless - unless a renderer still renders the type
|
||||
// (e.g. a deleted-but-rendered suggestion-mode subtree)
|
||||
if (type._item === null || !type._item.deleted || rendererContentLength(renderer, type._item) > 0) {
|
||||
index = rendererContentLength(renderer, right) === 0 ? 0 : (res.diff + (assoc >= 0 ? 0 : 1)) // adjust position based on left association if necessary
|
||||
let n = right.left
|
||||
while (n !== null) {
|
||||
index += rendererContentLength(renderer, n)
|
||||
n = n.left
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (tname !== null) {
|
||||
type = doc.get(tname)
|
||||
} else if (typeID !== null) {
|
||||
if (store.getClock(typeID.client) <= typeID.clock) {
|
||||
// type does not exist yet
|
||||
return null
|
||||
}
|
||||
const { item } = followUndoneDeletions ? followRedone(store, typeID) : { item: store.getItem(typeID) }
|
||||
if (item instanceof Item && item.content instanceof ContentType) {
|
||||
type = item.content.type
|
||||
} else {
|
||||
// struct is garbage collected
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
if (assoc >= 0) {
|
||||
index = type._length
|
||||
} else {
|
||||
index = 0
|
||||
}
|
||||
}
|
||||
return createAbsolutePosition(type, index, rpos.assoc)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelativePosition|null} a
|
||||
* @param {RelativePosition|null} b
|
||||
* @return {boolean}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const compareRelativePositions = (a, b) => a === b || (
|
||||
a !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type) && a.assoc === b.assoc
|
||||
)
|
||||
702
src/utils/Renderer.js
Normal file
702
src/utils/Renderer.js
Normal file
@@ -0,0 +1,702 @@
|
||||
import { ObservableV2 } from 'lib0/observable'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
|
||||
import { getItemCleanStart } from './transaction-helpers.js'
|
||||
import { diffIdSet, createInsertSetFromStructStore, createDeleteSetFromStructStore, insertIntoIdSet, mergeIdSets, intersectSets, createIdSet, createIdSetFromIdMap, writeIdSet, createIdMapFromIdSet, insertIntoIdMap, diffIdMap, createIdMap, mergeIdMaps, intersectMaps, createMaybeAttrRange, createContentAttribute } from './ids.js'
|
||||
import { ContentDeleted, ContentFormat, ContentType } from '../structs/Item.js'
|
||||
import { createID } from './ID.js'
|
||||
import { writeStructsFromIdSet } from './encoding-helpers.js'
|
||||
import { applyUpdate, encodeStateAsUpdate } from './encoding.js'
|
||||
import { UpdateEncoderV1 } from './UpdateEncoder.js'
|
||||
import { transact } from './Transaction.js'
|
||||
import { UndoManager, StackItem } from './UndoManager.js'
|
||||
|
||||
import { $renderer, AttributedContent } from './renderer-helpers.js'
|
||||
|
||||
export { AbstractRenderer, rendererContentLength, $renderer } from './renderer-helpers.js'
|
||||
|
||||
/**
|
||||
* Emit a single content piece into `contents`, applying the rendering rules of
|
||||
* {@link AttributionsRenderer}. `inRC` is whether the piece is in `renderedContent` (renders normally,
|
||||
* even when the item is deleted — a "restore"); `attrs` is its attribution (or `null`).
|
||||
*
|
||||
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||
* @param {AbstractContent} c
|
||||
* @param {number} clock
|
||||
* @param {boolean} deleted - the item's doc deleted flag
|
||||
* @param {boolean} inRC - whether this piece is in `renderedContent`
|
||||
* @param {Array<ContentAttribute<any>>|null} attrs
|
||||
* @param {0|1|2|3} shouldRender - see {@link AbstractRenderer#readContent}
|
||||
*/
|
||||
const pushAttributedPiece = (contents, c, clock, deleted, inRC, attrs, shouldRender) => {
|
||||
if (inRC || attrs != null) {
|
||||
// Visible: rendered normally (in `renderedContent`) and/or attributed. A restored deletion
|
||||
// (`inRC`) renders as alive, so its effective `deleted` flag is cleared.
|
||||
contents.push(new AttributedContent(c, clock, deleted && !inRC, attrs, shouldRender))
|
||||
} else if (deleted && shouldRender !== 0 && shouldRender !== 3) {
|
||||
// Genuinely deleted, unattributed, not restored: preserve the legacy retain/delete-op
|
||||
// behavior (mode 3 renders unattributed deleted content as nothing — see DiffRenderer).
|
||||
contents.push(new AttributedContent(c, clock, true, null, shouldRender))
|
||||
}
|
||||
// else: hidden alive content (or invisible deleted content under mode 0/3) — emit nothing.
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders content with attributions, given a single `attributions` {@link ContentMap} of how to
|
||||
* attribute content and an optional `renderedContent` {@link IdSet} of what renders.
|
||||
*
|
||||
* - `attributions` (inserts ∪ deletes) is merged into a single `renderAs` map; content it covers
|
||||
* always renders, carrying its attribution.
|
||||
* - `renderedContent` defines the content that renders *normally* — even if the item is marked
|
||||
* deleted in the doc (a "restore"). It defaults to the doc's alive content (`inserts − deletes`),
|
||||
* applied implicitly (a piece is in the default set ⟺ its item is not `deleted`), so the common
|
||||
* "pure attribution overlay" case does no extra work. When a custom set is supplied it is
|
||||
* authoritative: alive content omitted from it (and unattributed) is hidden.
|
||||
*
|
||||
* Restoring deleted content requires `gc: false` on the doc (the renderer does not rehydrate
|
||||
* garbage-collected content, unlike {@link DiffRenderer}).
|
||||
*
|
||||
* @implements AbstractRenderer
|
||||
*
|
||||
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||
*/
|
||||
export class AttributionsRenderer extends ObservableV2 {
|
||||
/**
|
||||
* @param {ContentMap} attributions - how to attribute content (`{ inserts, deletes }` IdMaps)
|
||||
* @param {Object} [options]
|
||||
* @param {IdSet?} [options.renderedContent] - content that renders normally; defaults to the
|
||||
* doc's alive content (`inserts − deletes`), applied implicitly.
|
||||
*/
|
||||
constructor (attributions, { renderedContent = null } = {}) {
|
||||
super()
|
||||
/**
|
||||
* The two attribution maps merged into one — `readContent` consults this for how to attribute
|
||||
* a piece.
|
||||
* @type {IdMap<any>}
|
||||
*/
|
||||
this.renderAs = mergeIdMaps([attributions.inserts, attributions.deletes])
|
||||
/**
|
||||
* Coverage of `renderAs` (ids that carry attributions). The small set `hasItem` checks. May
|
||||
* over-approximate what actually renders — `readContent` remains authoritative. See
|
||||
* {@link AbstractRenderer#attributed}.
|
||||
* @type {IdSet}
|
||||
*/
|
||||
this.attributed = createIdSetFromIdMap(this.renderAs)
|
||||
/**
|
||||
* Custom "content that renders normally" set, or `null` to use the implicit default
|
||||
* (alive ⟺ `!item.deleted`).
|
||||
* @type {IdSet?}
|
||||
*/
|
||||
this.renderedContent = renderedContent
|
||||
/**
|
||||
* Union of `renderedContent` and `attributed` — the single cheap "does this produce visible
|
||||
* output?" set. Only materialized when a custom `renderedContent` is supplied.
|
||||
* @type {IdSet?}
|
||||
*/
|
||||
this.rendered = renderedContent === null ? null : mergeIdSets([renderedContent, this.attributed])
|
||||
}
|
||||
|
||||
get $type () { return $renderer }
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasItem (item) {
|
||||
const { client, clock } = item.id
|
||||
if (this.attributed.intersects(client, clock, item.length)) return true
|
||||
if (this.renderedContent === null) return false
|
||||
// Custom renderedContent differs from the generic fast path for: deleted content that must be
|
||||
// restored, and alive content that must be hidden (not fully covered).
|
||||
return item.deleted
|
||||
? this.renderedContent.intersects(client, clock, item.length)
|
||||
: !this.renderedContent.covers(client, clock, item.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
* @param {boolean} deleted
|
||||
* @param {AbstractContent} content
|
||||
* @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent)
|
||||
*/
|
||||
readContent (contents, client, clock, deleted, content, shouldRender) {
|
||||
const total = content.getLength()
|
||||
/**
|
||||
* Segments of the item partitioned by `renderedContent`, or `null` when `inRC` is uniform over
|
||||
* the whole item (the common case: default set, or a custom set that fully contains/excludes
|
||||
* the item). `null` lets us slice `renderAs` once.
|
||||
* @type {Array<import('./ids.js').MaybeIdRange>?}
|
||||
*/
|
||||
let outer = null
|
||||
let inRC = !deleted // implicit default: alive ⟺ rendered normally
|
||||
if (this.renderedContent !== null) {
|
||||
outer = this.renderedContent.slice(client, clock, total)
|
||||
if (outer.length === 1) {
|
||||
inRC = outer[0].exists
|
||||
outer = null
|
||||
}
|
||||
}
|
||||
if (outer === null) {
|
||||
// Uniform `inRC` — a single `renderAs` slice fixes the attribution boundaries.
|
||||
const slice = this.renderAs.slice(client, clock, total)
|
||||
let rest = slice.length === 1 ? content : content.copy()
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
const s = slice[i]
|
||||
const c = rest
|
||||
if (i < slice.length - 1) rest = c.splice(s.len)
|
||||
pushAttributedPiece(contents, c, s.clock, deleted, inRC, s.attrs, shouldRender)
|
||||
}
|
||||
return
|
||||
}
|
||||
// General case: partition by `renderedContent` (outer, fixes `inRC`/effectiveDeleted), then by
|
||||
// `renderAs` (inner, fixes attrs). Multiple outer segments ⇒ multi-id (string-like) content, so
|
||||
// copying to avoid mutating the caller's content is safe.
|
||||
let rest = content.copy()
|
||||
for (let oi = 0; oi < outer.length; oi++) {
|
||||
const rcSeg = outer[oi]
|
||||
const slice = this.renderAs.slice(client, rcSeg.clock, rcSeg.len)
|
||||
for (let ii = 0; ii < slice.length; ii++) {
|
||||
const s = slice[ii]
|
||||
const last = oi === outer.length - 1 && ii === slice.length - 1
|
||||
const c = rest
|
||||
if (!last) rest = c.splice(s.len)
|
||||
pushAttributedPiece(contents, c, s.clock, deleted, rcSeg.exists, s.attrs, shouldRender)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {number}
|
||||
*/
|
||||
contentLength (item) {
|
||||
if (!item.content.isCountable()) {
|
||||
return 0
|
||||
}
|
||||
const { client, clock } = item.id
|
||||
if (this.renderedContent === null) {
|
||||
// Default: alive content renders in full; deleted content only where attributed.
|
||||
return item.deleted ? this.renderAs.coveredLength(client, clock, item.length) : item.length
|
||||
}
|
||||
// Custom: rendered length is |item ∩ rendered| (renderedContent ∪ attributed).
|
||||
return /** @type {IdSet} */ (this.rendered).coveredLength(client, clock, item.length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentMap} attributions - how to attribute content (`{ inserts, deletes }` IdMaps)
|
||||
* @param {Object} [options]
|
||||
* @param {IdSet?} [options.renderedContent] - content that renders normally; defaults to the doc's
|
||||
* alive content (`inserts − deletes`).
|
||||
*/
|
||||
export const createAttributionsRenderer = (attributions, options) => new AttributionsRenderer(attributions, options)
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
* @param {number} len
|
||||
*/
|
||||
const getItemContent = (store, client, clock, len) => {
|
||||
// Retrieved item is never more fragmented than the newer item.
|
||||
const prevItem = store.getItem(createID(client, clock))
|
||||
const diffStart = clock - prevItem.id.clock
|
||||
let content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
|
||||
// trim itemContent to the correct size.
|
||||
if (diffStart > 0) {
|
||||
content = content.splice(diffStart)
|
||||
}
|
||||
if (len < content.getLength()) {
|
||||
content.splice(len)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the whole subtree of an accepted pending node insert. A nested item can only exist in
|
||||
* the suggestion doc if its parent does, so every attribute entry and every child of an accepted
|
||||
* node belongs to the same suggestion - the attributes in particular: shipped without them, the
|
||||
* node that reaches the base doc is not the node that was suggested (an image without its `src`,
|
||||
* a paragraph without its text). Deleted nested items are transient (inserted and deleted within
|
||||
* the suggestion) and are collected like the top-level `collectAll` case; the receiving document
|
||||
* filters what it already holds.
|
||||
*
|
||||
* @param {import('../ynode.js').YNode} type
|
||||
* @param {IdSet} inserts
|
||||
* @param {IdSet} deletes
|
||||
*/
|
||||
const collectSubtree = (type, inserts, deletes) => {
|
||||
/**
|
||||
* @type {Array<Item|null>}
|
||||
*/
|
||||
const chains = [type._start]
|
||||
type._map.forEach(item => { chains.push(item) })
|
||||
for (const chain of chains) {
|
||||
// list items chain to the right, map entries to the left (their overwritten values)
|
||||
for (let item = chain; item != null; item = item.parentSub != null ? item.left : item.right) {
|
||||
inserts.add(item.id.client, item.id.clock, item.length)
|
||||
if (item.deleted) {
|
||||
deletes.add(item.id.client, item.id.clock, item.length)
|
||||
} else if (item.content instanceof ContentType) {
|
||||
collectSubtree(item.content.type, inserts, deletes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction?} tr - only specify this if you want to fill the content of deleted content
|
||||
* @param {DiffRenderer} renderer
|
||||
* @param {ID} start
|
||||
* @param {ID} end
|
||||
* @param {boolean} collectAll - collect as many items as possible. Accept adding redundant changes.
|
||||
* An accepted node insert then also collects its subtree (see {@link collectSubtree}).
|
||||
*/
|
||||
const collectSuggestedChanges = (tr, renderer, start, end, collectAll) => {
|
||||
const inserts = createIdSet()
|
||||
const deletes = createIdSet()
|
||||
const store = renderer._nextDoc.store
|
||||
/**
|
||||
* make sure to collect suggestions until all formats are closed
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const openedCollectedFormats = new Set()
|
||||
/**
|
||||
* @type {Item?}
|
||||
*/
|
||||
let item = store.getItem(start)
|
||||
const endItem = start === end ? item : (end == null ? null : store.getItem(end))
|
||||
|
||||
// walk to the left and find first un-attributed change that is rendered
|
||||
while (item.left != null) {
|
||||
item = item.left
|
||||
if (item.content instanceof ContentFormat && item.content.value == null) {
|
||||
item = item.right
|
||||
break
|
||||
}
|
||||
if (!item.deleted) {
|
||||
const slice = renderer.inserts.slice(item.id.client, item.id.clock, item.length)
|
||||
if (slice.some(s => s.attrs === null)) {
|
||||
for (let i = slice.length - 1; i >= 0; i--) {
|
||||
const s = slice[i]
|
||||
if (s.attrs == null) break
|
||||
inserts.add(item.id.client, s.clock, s.len)
|
||||
if (collectAll && item.content instanceof ContentType) {
|
||||
collectSubtree(item.content.type, inserts, deletes)
|
||||
}
|
||||
}
|
||||
item = item.right
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
let foundEndItem = false
|
||||
// eslint-disable-next-line
|
||||
itemLoop: while (item != null) {
|
||||
const itemClient = item.id.client
|
||||
const slice = (item.deleted ? renderer.deletes : renderer.inserts).slice(itemClient, item.id.clock, item.length)
|
||||
foundEndItem ||= item === endItem
|
||||
if (item.deleted) {
|
||||
// item probably gc'd content. Need to split item and fill with content again
|
||||
for (let i = slice.length - 1; i >= 0; i--) {
|
||||
const s = slice[i]
|
||||
if (s.attrs != null || collectAll) {
|
||||
deletes.add(itemClient, s.clock, s.len)
|
||||
if (collectAll) {
|
||||
// in case item has been added and deleted this might be necessary. the forked document
|
||||
// will automatically filter this if it doesn't have it already.
|
||||
inserts.add(itemClient, s.clock, s.len)
|
||||
}
|
||||
}
|
||||
if (tr != null) {
|
||||
const splicedItem = getItemCleanStart(tr, createID(itemClient, s.clock))
|
||||
if (s.attrs != null) {
|
||||
splicedItem.content = getItemContent(renderer._prevDocStore, itemClient, s.clock, s.len)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (item.content instanceof ContentFormat) {
|
||||
const { key, value } = item.content
|
||||
if (value == null) {
|
||||
openedCollectedFormats.delete(key)
|
||||
} else {
|
||||
openedCollectedFormats.add(key)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
const s = slice[i]
|
||||
if (s.attrs != null) {
|
||||
inserts.add(itemClient, s.clock, s.len)
|
||||
if (collectAll && item.content instanceof ContentType) {
|
||||
collectSubtree(item.content.type, inserts, deletes)
|
||||
}
|
||||
} else if (foundEndItem && openedCollectedFormats.size === 0) {
|
||||
// eslint-disable-next-line
|
||||
break itemLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
item = item.right
|
||||
}
|
||||
return { inserts, deletes }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IdMap<any>|undefined} attrs
|
||||
* @param {IdSet} slice
|
||||
*
|
||||
*/
|
||||
const extractAttributions = (attrs, slice) => attrs == null ? createIdMapFromIdSet(slice, []) : mergeIdMaps([intersectMaps(attrs, slice), createIdMapFromIdSet(slice, [])])
|
||||
|
||||
/**
|
||||
* @implements AbstractRenderer
|
||||
*
|
||||
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||
*/
|
||||
export class DiffRenderer extends ObservableV2 {
|
||||
/**
|
||||
* @param {Doc} prevDoc
|
||||
* @param {Doc} nextDoc
|
||||
* @param {Object} [options] - options for the renderer
|
||||
* @param {ContentMap?} [options.attributions] - the attributions to apply to the diff
|
||||
*/
|
||||
constructor (prevDoc, nextDoc, { attributions = null } = {}) {
|
||||
super()
|
||||
const _nextDocInserts = createInsertSetFromStructStore(nextDoc.store, false) // unmaintained
|
||||
const _prevDocInserts = createInsertSetFromStructStore(prevDoc.store, false) // unmaintained
|
||||
const nextDocDeletes = createDeleteSetFromStructStore(nextDoc.store) // maintained
|
||||
const prevDocDeletes = createDeleteSetFromStructStore(prevDoc.store) // maintained
|
||||
const insertDiff = diffIdSet(_nextDocInserts, _prevDocInserts)
|
||||
const deleteDiff = diffIdSet(nextDocDeletes, prevDocDeletes)
|
||||
this.inserts = extractAttributions(attributions?.inserts, insertDiff)
|
||||
this.deletes = extractAttributions(attributions?.deletes, deleteDiff)
|
||||
/**
|
||||
* Raw coverage of `inserts` ∪ `deletes`, maintained alongside them. Over-approximates the
|
||||
* actually-rendered set (e.g. it keeps suggested-inserts that were deleted later) —
|
||||
* `readContent` remains authoritative. See {@link AbstractRenderer#attributed}.
|
||||
* @type {IdSet}
|
||||
*/
|
||||
this.attributed = mergeIdSets([insertDiff, deleteDiff])
|
||||
this._prevDoc = prevDoc
|
||||
this._prevDocStore = prevDoc.store
|
||||
this._nextDoc = nextDoc
|
||||
// update before observer calls fired
|
||||
this._nextBOH = nextDoc.on('beforeObserverCalls', tr => {
|
||||
// update inserts
|
||||
const diffInserts = diffIdSet(tr.insertSet, _prevDocInserts)
|
||||
insertIntoIdMap(this.inserts, extractAttributions(attributions?.inserts, diffInserts))
|
||||
// update deletes
|
||||
const diffDeletes = diffIdSet(diffIdSet(tr.deleteSet, prevDocDeletes), this.inserts)
|
||||
insertIntoIdMap(this.deletes, extractAttributions(attributions?.deletes, diffDeletes))
|
||||
insertIntoIdSet(this.attributed, diffInserts)
|
||||
insertIntoIdSet(this.attributed, diffDeletes)
|
||||
// @todo fire update ranges on `diffInserts` and `diffDeletes`
|
||||
})
|
||||
this._prevBOH = prevDoc.on('beforeObserverCalls', tr => {
|
||||
insertIntoIdSet(_prevDocInserts, tr.insertSet)
|
||||
insertIntoIdSet(prevDocDeletes, tr.deleteSet)
|
||||
if (tr.insertSet.clients.size < 2) {
|
||||
tr.insertSet.forEach((attrRange, client) => {
|
||||
this.inserts.delete(client, attrRange.clock, attrRange.len)
|
||||
})
|
||||
} else {
|
||||
this.inserts = diffIdMap(this.inserts, tr.insertSet)
|
||||
}
|
||||
// insertIntoIdMap(this.deletes, createIdMapFromIdSet(intersectSets(tr.deleteSet, this.deletes), [createAttributionItem('acceptDelete', 'unknown')]))
|
||||
if (tr.deleteSet.clients.size < 2) {
|
||||
tr.deleteSet.forEach((attrRange, client) => {
|
||||
this.deletes.delete(client, attrRange.clock, attrRange.len)
|
||||
})
|
||||
} else {
|
||||
this.deletes = diffIdMap(this.deletes, tr.deleteSet)
|
||||
}
|
||||
// evict the accepted/rejected ranges from the coverage set, then re-add whatever the maps
|
||||
// still claim: `tr.insertSet` only evicts insert-attributions and `tr.deleteSet` only evicts
|
||||
// delete-attributions, but a single range can be covered by both maps (e.g. an accepted
|
||||
// insert with a still-pending delete suggestion on the same ids must stay attributed).
|
||||
const evicted = mergeIdSets([tr.insertSet, tr.deleteSet])
|
||||
this.attributed = diffIdSet(this.attributed, evicted)
|
||||
insertIntoIdSet(this.attributed, intersectSets(evicted, this.inserts))
|
||||
insertIntoIdSet(this.attributed, intersectSets(evicted, this.deletes))
|
||||
// fire event of "changed" attributions. exclude items that were added & deleted in the same
|
||||
// transaction
|
||||
this.emit('change', [diffIdSet(mergeIdSets([tr.insertSet, tr.deleteSet]), intersectSets(tr.insertSet, tr.deleteSet)), tr.origin, tr.local])
|
||||
})
|
||||
// changes from prevDoc should always flow into suggestionDoc
|
||||
// changes from suggestionDoc only flow into ydoc if suggestion-mode is disabled
|
||||
this._prevUpdateListener = prevDoc.on('update', (update, origin) => {
|
||||
origin !== this && applyUpdate(nextDoc, update)
|
||||
})
|
||||
this._ndUpdateListener = nextDoc.on('update', (update, origin, _doc, tr) => {
|
||||
// only if event is local and suggestion mode is enabled
|
||||
if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === origin))) {
|
||||
applyUpdate(prevDoc, update, this)
|
||||
}
|
||||
})
|
||||
this._afterTrListener = nextDoc.on('afterTransaction', (tr) => {
|
||||
// apply deletes on attributed deletes (content that is already deleted, but is rendered by
|
||||
// the renderer)
|
||||
if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === tr.origin))) {
|
||||
const attributedDeletes = tr.meta.get('attributedDeletes')
|
||||
if (attributedDeletes != null) {
|
||||
transact(prevDoc, () => {
|
||||
// apply attributed deletes if there are any
|
||||
const ds = new UpdateEncoderV1()
|
||||
encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs
|
||||
writeIdSet(ds, attributedDeletes)
|
||||
applyUpdate(prevDoc, ds.toUint8Array())
|
||||
}, this)
|
||||
}
|
||||
}
|
||||
})
|
||||
this.suggestionMode = true
|
||||
/**
|
||||
* Optionally limit origins that may sync changes to the main doc if suggestion-mode is
|
||||
* disabled.
|
||||
*
|
||||
* @type {Array<any>?}
|
||||
*/
|
||||
this.suggestionOrigins = null
|
||||
this._destroyHandler = nextDoc.on('destroy', this.destroy.bind(this))
|
||||
prevDoc.on('destroy', this._destroyHandler)
|
||||
}
|
||||
|
||||
get $type () { return $renderer }
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasItem (item) {
|
||||
return this.attributed.intersects(item.id.client, item.id.clock, item.length)
|
||||
}
|
||||
|
||||
destroy () {
|
||||
super.destroy()
|
||||
this._nextDoc.off('destroy', this._destroyHandler)
|
||||
this._prevDoc.off('destroy', this._destroyHandler)
|
||||
this._nextDoc.off('beforeObserverCalls', this._nextBOH)
|
||||
this._prevDoc.off('beforeObserverCalls', this._prevBOH)
|
||||
this._prevDoc.off('update', this._prevUpdateListener)
|
||||
this._nextDoc.off('update', this._ndUpdateListener)
|
||||
this._nextDoc.off('afterTransaction', this._afterTrListener)
|
||||
}
|
||||
|
||||
acceptAllChanges () {
|
||||
applyUpdate(this._prevDoc, encodeStateAsUpdate(this._nextDoc))
|
||||
}
|
||||
|
||||
rejectAllChanges () {
|
||||
this._prevDoc.transact(tr => {
|
||||
applyUpdate(this._prevDoc, encodeStateAsUpdate(this._nextDoc))
|
||||
const um = new UndoManager(this._prevDoc)
|
||||
um.undoStack.push(new StackItem(tr.insertSet, tr.deleteSet))
|
||||
um.undo()
|
||||
um.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} start
|
||||
* @param {ID} end
|
||||
*/
|
||||
acceptChanges (start, end = start) {
|
||||
const { inserts, deletes } = collectSuggestedChanges(null, this, start, end, true)
|
||||
const encoder = new UpdateEncoderV1()
|
||||
writeStructsFromIdSet(encoder, this._nextDoc.store, inserts)
|
||||
writeIdSet(encoder, deletes)
|
||||
applyUpdate(this._prevDoc, encoder.toUint8Array())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} start
|
||||
* @param {ID} end
|
||||
*/
|
||||
rejectChanges (start, end = start) {
|
||||
this._nextDoc.transact(tr => {
|
||||
const { inserts, deletes } = collectSuggestedChanges(tr, this, start, end, false)
|
||||
const encoder = new UpdateEncoderV1()
|
||||
writeStructsFromIdSet(encoder, this._nextDoc.store, inserts)
|
||||
writeIdSet(encoder, deletes)
|
||||
const um = new UndoManager(this._nextDoc)
|
||||
um.undoStack.push(new StackItem(inserts, deletes))
|
||||
um.undo()
|
||||
um.destroy()
|
||||
})
|
||||
this.acceptChanges(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
* @param {boolean} deleted
|
||||
* @param {AbstractContent} _content
|
||||
* @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent)
|
||||
*/
|
||||
readContent (contents, client, clock, deleted, _content, shouldRender) {
|
||||
const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, _content.getLength())
|
||||
/**
|
||||
* @type {AbstractContent?}
|
||||
*/
|
||||
let content = slice.length === 1 ? _content : _content.copy()
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
const s = slice[i]
|
||||
if (content == null || content instanceof ContentDeleted) {
|
||||
if ((!shouldRender && s.attrs == null) || this.inserts.has(client, s.clock)) {
|
||||
continue
|
||||
}
|
||||
// Retrieved item is never more fragmented than the newer item.
|
||||
const prevItem = this._prevDocStore.getItem(createID(client, s.clock))
|
||||
const diffStart = s.clock - prevItem.id.clock
|
||||
content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
|
||||
// trim itemContent to the correct size.
|
||||
if (diffStart > 0) {
|
||||
content = content.splice(diffStart)
|
||||
}
|
||||
}
|
||||
const c = /** @type {AbstractContent} */ (content)
|
||||
const clen = c.getLength()
|
||||
if (clen < s.len) {
|
||||
slice.splice(i + 1, 0, createMaybeAttrRange(s.clock + clen, s.len - clen, s.attrs))
|
||||
s.len = clen
|
||||
}
|
||||
content = s.len < clen ? c.splice(s.len) : null
|
||||
// mode 3 (fresh content deleted in the same transaction) renders as an insert only where
|
||||
// the renderer attributes it — unattributed deleted content is invisible and renders as
|
||||
// *nothing* (there is nothing to insert, and a `delete` op would misapply: the consuming
|
||||
// state has never seen this content)
|
||||
if (!deleted || s.attrs != null || (shouldRender !== 0 && shouldRender !== 3)) {
|
||||
contents.push(new AttributedContent(c, s.clock, deleted, s.attrs, shouldRender))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {number}
|
||||
*/
|
||||
contentLength (item) {
|
||||
if (!item.deleted) {
|
||||
return item.content.isCountable() ? item.length : 0
|
||||
}
|
||||
/**
|
||||
* @type {Array<AttributedContent<any>>}
|
||||
*/
|
||||
const cs = []
|
||||
this.readContent(cs, item.id.client, item.id.clock, true, item.content, 0)
|
||||
return cs.reduce((cnt, c) => cnt + ((c.attrs != null && c.content.isCountable()) ? c.content.getLength() : 0), 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute changes from ydoc1 to ydoc2.
|
||||
*
|
||||
* @param {Doc} prevDoc
|
||||
* @param {Doc} nextDoc
|
||||
* @param {Object} [options] - options for the renderer
|
||||
* @param {ContentMap?} [options.attributions] - the attributions to apply to the diff
|
||||
*/
|
||||
export const createDiffRenderer = (prevDoc, nextDoc, options) => new DiffRenderer(prevDoc, nextDoc, options)
|
||||
|
||||
/**
|
||||
* Intended for projects that used the v13 snapshot feature. With this renderer you can
|
||||
* read content similar to the previous snapshot api. Requires that `ydoc.gc` is turned off.
|
||||
*
|
||||
* @implements AbstractRenderer
|
||||
*
|
||||
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||
*/
|
||||
export class SnapshotRenderer extends ObservableV2 {
|
||||
/**
|
||||
* @param {Snapshot} prevSnapshot
|
||||
* @param {Snapshot} nextSnapshot
|
||||
* @param {Object} [options] - options for the renderer
|
||||
* @param {Array<ContentAttribute>} [options.attrs] - the attributes to apply to the diff
|
||||
*/
|
||||
constructor (prevSnapshot, nextSnapshot) {
|
||||
super()
|
||||
this.prevSnapshot = prevSnapshot
|
||||
this.nextSnapshot = nextSnapshot
|
||||
const inserts = createIdMap()
|
||||
const deletes = createIdMapFromIdSet(diffIdSet(nextSnapshot.ds, prevSnapshot.ds), [createContentAttribute('change', '')])
|
||||
nextSnapshot.sv.forEach((clock, client) => {
|
||||
const prevClock = prevSnapshot.sv.get(client) || 0
|
||||
inserts.add(client, 0, prevClock, []) // content is included in prevSnapshot is rendered without attributes
|
||||
inserts.add(client, prevClock, clock - prevClock, [createContentAttribute('change', '')]) // content is rendered as "inserted"
|
||||
})
|
||||
this.attrs = mergeIdMaps([diffIdMap(inserts, prevSnapshot.ds), deletes])
|
||||
/**
|
||||
* Coverage of `attrs` (everything up to `nextSnapshot`). Content *after* the snapshot must be
|
||||
* hidden rather than rendered normally, which a finite set cannot express — `hasItem`
|
||||
* additionally claims all future content. See {@link AbstractRenderer#attributed}.
|
||||
* @type {IdSet}
|
||||
*/
|
||||
this.attributed = createIdSetFromIdMap(this.attrs)
|
||||
}
|
||||
|
||||
get $type () { return $renderer }
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasItem (item) {
|
||||
// claim future content (item ids at/after the snapshot's state vector, including clients the
|
||||
// snapshot has never seen) — `readContent` hides it, the generic path would render it
|
||||
return (this.nextSnapshot.sv.get(item.id.client) ?? 0) < item.id.clock + item.length ||
|
||||
this.attributed.intersects(item.id.client, item.id.clock, item.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
* @param {boolean} _deleted
|
||||
* @param {AbstractContent} content
|
||||
* @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent)
|
||||
*/
|
||||
readContent (contents, client, clock, _deleted, content, shouldRender) {
|
||||
if ((this.nextSnapshot.sv.get(client) ?? 0) <= clock) return // future item that should not be displayed
|
||||
const slice = this.attrs.slice(client, clock, content.getLength())
|
||||
content = slice.length === 1 ? content : content.copy()
|
||||
slice.forEach(s => {
|
||||
const deleted = this.nextSnapshot.ds.has(client, s.clock)
|
||||
const nonExistend = (this.nextSnapshot.sv.get(client) ?? 0) <= s.clock
|
||||
const c = content
|
||||
if (s.len < c.getLength()) {
|
||||
content = c.splice(s.len)
|
||||
}
|
||||
if (nonExistend) return
|
||||
if (shouldRender || !deleted || (s.attrs != null && s.attrs.length > 0)) {
|
||||
let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null
|
||||
if (s.attrs?.length === 0) {
|
||||
attrsWithoutChange = null
|
||||
}
|
||||
contents.push(new AttributedContent(c, s.clock, deleted, attrsWithoutChange, shouldRender))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @return {number}
|
||||
*/
|
||||
contentLength (item) {
|
||||
return item.content.isCountable()
|
||||
? (item.deleted
|
||||
? this.attrs.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0)
|
||||
: item.length
|
||||
)
|
||||
: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} prevSnapshot
|
||||
* @param {Snapshot} nextSnapshot
|
||||
*/
|
||||
export const createSnapshotRenderer = (prevSnapshot, nextSnapshot = prevSnapshot) => new SnapshotRenderer(prevSnapshot, nextSnapshot)
|
||||
198
src/utils/Snapshot.js
Normal file
198
src/utils/Snapshot.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as map from 'lib0/map'
|
||||
import * as set from 'lib0/set'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
|
||||
import { createID } from './ID.js'
|
||||
import { createDeleteSetFromStructStore, createIdSet, iterateStructsByIdSet, equalIdSets, mergeIdSets, writeIdSet, readIdSet } from './ids.js'
|
||||
import { getStateVector } from './StructStore.js'
|
||||
import { findIndexSS, getItemCleanStart } from './transaction-helpers.js'
|
||||
import { writeStateVector, readStateVector, applyUpdateV2 } from './encoding.js'
|
||||
import { UpdateDecoderV1, UpdateDecoderV2, IdSetDecoderV1, IdSetDecoderV2 } from './UpdateDecoder.js'
|
||||
import { UpdateEncoderV2, IdSetEncoderV1, IdSetEncoderV2 } from './UpdateEncoder.js'
|
||||
import { LazyStructReader } from './updates.js'
|
||||
import { Doc } from './Doc.js'
|
||||
|
||||
export class Snapshot {
|
||||
/**
|
||||
* @param {IdSet} ds
|
||||
* @param {Map<number,number>} sv state map
|
||||
*/
|
||||
constructor (ds, sv) {
|
||||
/**
|
||||
* @type {IdSet}
|
||||
*/
|
||||
this.ds = ds
|
||||
/**
|
||||
* State Map
|
||||
* @type {Map<number,number>}
|
||||
*/
|
||||
this.sv = sv
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snap1
|
||||
* @param {Snapshot} snap2
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const equalSnapshots = (snap1, snap2) => {
|
||||
const sv1 = snap1.sv
|
||||
const sv2 = snap2.sv
|
||||
if (sv1.size !== sv2.size) {
|
||||
return false
|
||||
}
|
||||
for (const [key, value] of sv1.entries()) {
|
||||
if (sv2.get(key) !== value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return equalIdSets(snap1.ds, snap2.ds)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snapshot
|
||||
* @param {IdSetEncoderV1 | IdSetEncoderV2} [encoder]
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const encodeSnapshotV2 = (snapshot, encoder = new IdSetEncoderV2()) => {
|
||||
writeIdSet(encoder, snapshot.ds)
|
||||
writeStateVector(encoder, snapshot.sv)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snapshot
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const encodeSnapshot = snapshot => encodeSnapshotV2(snapshot, new IdSetEncoderV1())
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
* @param {IdSetDecoderV1 | IdSetDecoderV2} [decoder]
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const decodeSnapshotV2 = (buf, decoder = new IdSetDecoderV2(decoding.createDecoder(buf))) => {
|
||||
return new Snapshot(readIdSet(decoder), readStateVector(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const decodeSnapshot = buf => decodeSnapshotV2(buf, new IdSetDecoderV1(decoding.createDecoder(buf)))
|
||||
|
||||
/**
|
||||
* @param {IdSet} ds
|
||||
* @param {Map<number,number>} sm
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
|
||||
|
||||
export const emptySnapshot = createSnapshot(createIdSet(), new Map())
|
||||
|
||||
/**
|
||||
* @param {Doc} doc
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store))
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Snapshot} snapshot
|
||||
*/
|
||||
export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
|
||||
const meta = map.setIfUndefined(transaction.meta, splitSnapshotAffectedStructs, set.create)
|
||||
const store = transaction.doc.store
|
||||
// check if we already split for this snapshot
|
||||
if (!meta.has(snapshot)) {
|
||||
snapshot.sv.forEach((clock, client) => {
|
||||
if (clock < store.getClock(client)) {
|
||||
getItemCleanStart(transaction, createID(client, clock))
|
||||
}
|
||||
})
|
||||
iterateStructsByIdSet(transaction, snapshot.ds, _item => {})
|
||||
meta.add(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* const ydoc = new Y.Doc({ gc: false })
|
||||
* ydoc.get().insert(0, 'world!')
|
||||
* const snapshot = Y.snapshot(ydoc)
|
||||
* ydoc.get().insert(0, 'hello ')
|
||||
* const restored = Y.createDocFromSnapshot(ydoc, snapshot)
|
||||
* assert(restored.get().toString() === 'world!')
|
||||
*
|
||||
* @param {Doc} originDoc
|
||||
* @param {Snapshot} snapshot
|
||||
* @param {Doc} [newDoc] Optionally, you may define the Yjs document that receives the data from originDoc
|
||||
* @return {Doc}
|
||||
*/
|
||||
export const createDocFromSnapshot = (originDoc, snapshot, newDoc = new Doc()) => {
|
||||
if (originDoc.gc) {
|
||||
// we should not try to restore a GC-ed document, because some of the restored items might have their content deleted
|
||||
throw new Error('Garbage-collection must be disabled in `originDoc`!')
|
||||
}
|
||||
const { sv, ds } = snapshot
|
||||
|
||||
const encoder = new UpdateEncoderV2()
|
||||
originDoc.transact(transaction => {
|
||||
let size = 0
|
||||
sv.forEach(clock => {
|
||||
if (clock > 0) {
|
||||
size++
|
||||
}
|
||||
})
|
||||
encoding.writeVarUint(encoder.restEncoder, size)
|
||||
// splitting the structs before writing them to the encoder
|
||||
for (const [client, clock] of sv) {
|
||||
if (clock === 0) {
|
||||
continue
|
||||
}
|
||||
if (clock < originDoc.store.getClock(client)) {
|
||||
getItemCleanStart(transaction, createID(client, clock))
|
||||
}
|
||||
const structs = originDoc.store.clients.get(client) || []
|
||||
const lastStructIndex = findIndexSS(structs, clock - 1)
|
||||
// write # encoded structs
|
||||
encoding.writeVarUint(encoder.restEncoder, lastStructIndex + 1)
|
||||
encoder.writeClient(client)
|
||||
// first clock written is 0
|
||||
encoding.writeVarUint(encoder.restEncoder, 0)
|
||||
for (let i = 0; i <= lastStructIndex; i++) {
|
||||
structs[i].write(encoder, 0, 0)
|
||||
}
|
||||
}
|
||||
writeIdSet(encoder, ds)
|
||||
})
|
||||
|
||||
applyUpdateV2(newDoc, encoder.toUint8Array(), 'snapshot')
|
||||
return newDoc
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snapshot
|
||||
* @param {Uint8Array} update
|
||||
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
|
||||
*/
|
||||
export const snapshotContainsUpdateV2 = (snapshot, update, YDecoder = UpdateDecoderV2) => {
|
||||
const structs = []
|
||||
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||
const lazyDecoder = new LazyStructReader(updateDecoder, false)
|
||||
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||
structs.push(curr)
|
||||
if ((snapshot.sv.get(curr.id.client) || 0) < curr.id.clock + curr.length) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const mergedDS = mergeIdSets([snapshot.ds, readIdSet(updateDecoder)])
|
||||
return equalIdSets(snapshot.ds, mergedDS)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snapshot
|
||||
* @param {Uint8Array} update
|
||||
*/
|
||||
export const snapshotContainsUpdate = (snapshot, update) => snapshotContainsUpdateV2(snapshot, update, UpdateDecoderV1)
|
||||
151
src/utils/StructStore.js
Normal file
151
src/utils/StructStore.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Skip } from '../structs/Skip.js'
|
||||
import { createID } from './ID.js'
|
||||
import { createDeleteSetFromStructStore, createIdSet } from './ids.js'
|
||||
import { findIndexSS } from './transaction-helpers.js'
|
||||
|
||||
export class StructStore {
|
||||
constructor () {
|
||||
/**
|
||||
* @type {Map<number,Array<GC|Item|Skip>>}
|
||||
*/
|
||||
this.clients = new Map()
|
||||
// this.ds = new IdSet()
|
||||
/**
|
||||
* @type {null | { missing: Map<number, number>, update: Uint8Array<ArrayBuffer> }}
|
||||
*/
|
||||
this.pendingStructs = null
|
||||
/**
|
||||
* @type {null | Uint8Array<ArrayBuffer>}
|
||||
*/
|
||||
this.pendingDs = null
|
||||
this.skips = createIdSet()
|
||||
}
|
||||
|
||||
get ds () {
|
||||
return createDeleteSetFromStructStore(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GC|Item|Skip} struct
|
||||
* @function
|
||||
*/
|
||||
add (struct) {
|
||||
let structs = this.clients.get(struct.id.client)
|
||||
if (structs === undefined) {
|
||||
structs = []
|
||||
this.clients.set(struct.id.client, structs)
|
||||
} else {
|
||||
const lastStruct = structs[structs.length - 1]
|
||||
if (lastStruct.id.clock + lastStruct.length !== struct.id.clock) {
|
||||
// this replaces an integrated skip
|
||||
let index = findIndexSS(structs, struct.id.clock)
|
||||
const skip = structs[index]
|
||||
const diffStart = struct.id.clock - skip.id.clock
|
||||
const diffEnd = skip.id.clock + skip.length - struct.id.clock - struct.length
|
||||
if (diffStart > 0) {
|
||||
structs.splice(index++, 0, new Skip(createID(struct.id.client, skip.id.clock), diffStart))
|
||||
}
|
||||
if (diffEnd > 0) {
|
||||
structs.splice(index + 1, 0, new Skip(createID(struct.id.client, struct.id.clock + struct.length), diffEnd))
|
||||
}
|
||||
structs[index] = struct
|
||||
this.skips.delete(struct.id.client, struct.id.clock, struct.length)
|
||||
return
|
||||
}
|
||||
}
|
||||
structs.push(struct)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||
*
|
||||
* @param {ID} id
|
||||
* @return {GC|Item}
|
||||
*/
|
||||
get (id) {
|
||||
const structs = /** @type {Array<GC|Item>} */ (this.clients.get(id.client))
|
||||
return structs[findIndexSS(structs, id.clock)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||
*
|
||||
* @param {ID} id
|
||||
* @return {Item}
|
||||
*/
|
||||
getItem (id) {
|
||||
const structs = /** @type {Array<GC|Item>} */ (this.clients.get(id.client))
|
||||
return /** @type {Item} */ (structs[findIndexSS(structs, id.clock)])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next expected clock for a specific client.
|
||||
*
|
||||
* @param {number} client
|
||||
* @return {number}
|
||||
*
|
||||
* @public
|
||||
* @function
|
||||
*/
|
||||
getClock (client) {
|
||||
const structs = this.clients.get(client)
|
||||
if (structs === undefined) {
|
||||
return 0
|
||||
}
|
||||
const lastStruct = structs[structs.length - 1]
|
||||
return lastStruct.id.clock + lastStruct.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a binary search on a sorted array
|
||||
* @param {ID} id
|
||||
* @return {{ structs: Array<GC|Item|Skip>, index: number }}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
getIndex (id) {
|
||||
const structs = this.clients.get(id.client) || []
|
||||
const index = findIndexSS(structs, id.clock)
|
||||
return { structs, index }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the states as a Map<client,clock>.
|
||||
* Note that clock refers to the next expected clock id.
|
||||
*
|
||||
* @param {StructStore} store
|
||||
* @return {Map<number,number>}
|
||||
*
|
||||
* @public
|
||||
* @function
|
||||
*/
|
||||
export const getStateVector = store => {
|
||||
const sm = new Map()
|
||||
store.clients.forEach((structs, client) => {
|
||||
const struct = structs[structs.length - 1]
|
||||
sm.set(client, struct.id.clock + struct.length)
|
||||
})
|
||||
store.skips.clients.forEach((range, client) => {
|
||||
sm.set(client, range.getIds()[0].clock)
|
||||
})
|
||||
return sm
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const integrityCheck = store => {
|
||||
store.clients.forEach(structs => {
|
||||
for (let i = 1; i < structs.length; i++) {
|
||||
const l = structs[i - 1]
|
||||
const r = structs[i]
|
||||
if (l.id.clock + l.length !== r.id.clock) {
|
||||
throw new Error('StructStore failed integrity check')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
446
src/utils/Transaction.js
Normal file
446
src/utils/Transaction.js
Normal file
@@ -0,0 +1,446 @@
|
||||
import * as error from 'lib0/error'
|
||||
import * as map from 'lib0/map'
|
||||
import * as math from 'lib0/math'
|
||||
import * as logging from 'lib0/logging'
|
||||
import { callAll } from 'lib0/function'
|
||||
|
||||
import { ContentFormat } from '../structs/Item.js'
|
||||
import { getStateVector } from './StructStore.js'
|
||||
import { callEventHandlerListeners } from './EventHandler.js'
|
||||
import { createIdSet, iterateStructsByIdSet } from './ids.js'
|
||||
import { GC } from '../structs/GC.js'
|
||||
import { YEvent } from './YEvent.js'
|
||||
import { writeUpdateMessageFromTransaction } from './encoding-helpers.js'
|
||||
import { UpdateEncoderV1, UpdateEncoderV2 } from './UpdateEncoder.js'
|
||||
import { findIndexSS, updateCurrentFormats, cleanupFormattingGap, tryGcDeleteSet, tryMerge, tryToMergeWithLefts, cleanupContextlessFormattingGap } from './transaction-helpers.js'
|
||||
import * as random from 'lib0/random'
|
||||
|
||||
export const generateNewClientId = random.uint53
|
||||
|
||||
/**
|
||||
* A transaction is created for every change on the Yjs model. It is possible
|
||||
* to bundle changes on the Yjs model in a single transaction to
|
||||
* minimize the number on messages sent and the number of observer calls.
|
||||
* If possible the user of this library should bundle as many changes as
|
||||
* possible. Here is an example to illustrate the advantages of bundling:
|
||||
*
|
||||
* @example
|
||||
* const ydoc = new Y.Doc()
|
||||
* const map = ydoc.get('map')
|
||||
* // Log content when change is triggered
|
||||
* map.observe(() => {
|
||||
* console.log('change triggered')
|
||||
* })
|
||||
* // Each change on the map type triggers a log message:
|
||||
* map.setAttr('a', 0) // => "change triggered"
|
||||
* map.setAttr('b', 0) // => "change triggered"
|
||||
* // When put in a transaction, it will trigger the log after the transaction:
|
||||
* ydoc.transact(() => {
|
||||
* map.setAttr('a', 1)
|
||||
* map.setAttr('b', 1)
|
||||
* }) // => "change triggered"
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class Transaction {
|
||||
/**
|
||||
* @param {Doc} doc
|
||||
* @param {any} origin
|
||||
* @param {boolean} local
|
||||
*/
|
||||
constructor (doc, origin, local) {
|
||||
/**
|
||||
* The Yjs instance.
|
||||
* @type {Doc}
|
||||
*/
|
||||
this.doc = doc
|
||||
/**
|
||||
* Describes the set of deleted items by ids
|
||||
*/
|
||||
this.deleteSet = createIdSet()
|
||||
/**
|
||||
* Describes the set of items that are cleaned up / deleted by ids. It is a subset of
|
||||
* this.deleteSet
|
||||
*/
|
||||
this.cleanUps = createIdSet()
|
||||
/**
|
||||
* Describes the set of inserted items by ids
|
||||
*/
|
||||
this.insertSet = createIdSet()
|
||||
/**
|
||||
* Holds the state before the transaction started.
|
||||
* @type {Map<Number,Number>?}
|
||||
*/
|
||||
this._beforeState = null
|
||||
/**
|
||||
* Holds the state after the transaction.
|
||||
* @type {Map<Number,Number>?}
|
||||
*/
|
||||
this._afterState = null
|
||||
/**
|
||||
* All types that were directly modified (property added or child
|
||||
* inserted/deleted). New types are not included in this Set.
|
||||
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
|
||||
* @type {Map<YNode,Set<String|null>>}
|
||||
*/
|
||||
this.changed = new Map()
|
||||
/**
|
||||
* Stores the events for the types that observe also child elements.
|
||||
* It is mainly used by `observeDeep`.
|
||||
* @type {Map<YNode,Array<YEvent<any>>>}
|
||||
*/
|
||||
this.changedParentTypes = new Map()
|
||||
/**
|
||||
* @type {Array<AbstractStruct>}
|
||||
*/
|
||||
this._mergeStructs = []
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
this.origin = origin
|
||||
/**
|
||||
* Stores meta information on the transaction
|
||||
* @type {Map<any,any>}
|
||||
*/
|
||||
this.meta = new Map()
|
||||
/**
|
||||
* Whether this change originates from this doc.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.local = local
|
||||
/**
|
||||
* @type {Set<Doc>}
|
||||
*/
|
||||
this.subdocsAdded = new Set()
|
||||
/**
|
||||
* @type {Set<Doc>}
|
||||
*/
|
||||
this.subdocsRemoved = new Set()
|
||||
/**
|
||||
* @type {Set<Doc>}
|
||||
*/
|
||||
this.subdocsLoaded = new Set()
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._needFormattingCleanup = false
|
||||
this._done = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the state before the transaction started.
|
||||
*
|
||||
* @deprecated
|
||||
* @type {Map<Number,Number>}
|
||||
*/
|
||||
get beforeState () {
|
||||
if (this._beforeState == null) {
|
||||
const sv = getStateVector(this.doc.store)
|
||||
this.insertSet.clients.forEach((ranges, client) => {
|
||||
sv.set(client, ranges.getIds()[0].clock)
|
||||
})
|
||||
this._beforeState = sv
|
||||
}
|
||||
return this._beforeState
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the state after the transaction.
|
||||
*
|
||||
* @deprecated
|
||||
* @type {Map<Number,Number>}
|
||||
*/
|
||||
get afterState () {
|
||||
if (!this._done) error.unexpectedCase()
|
||||
if (this._afterState == null) {
|
||||
const sv = getStateVector(this.doc.store)
|
||||
this.insertSet.clients.forEach((_ranges, client) => {
|
||||
const ranges = _ranges.getIds()
|
||||
const d = ranges[ranges.length - 1]
|
||||
sv.set(client, d.clock + d.len)
|
||||
})
|
||||
this._afterState = sv
|
||||
}
|
||||
return this._afterState
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is experimental and subject to change / be removed.
|
||||
*
|
||||
* Ideally, we don't need this function at all. Formats should be cleaned up
|
||||
* automatically after each change. This function iterates twice over the complete YText type
|
||||
* and removes unnecessary formats. This is also helpful for testing.
|
||||
*
|
||||
* This function won't be exported anymore as soon as there is confidence that the YText type works as intended.
|
||||
*
|
||||
* @param {YNode} type
|
||||
* @return {number} How many formats have been cleaned up.
|
||||
*/
|
||||
export const cleanupYTextFormatting = type => {
|
||||
if (!type.doc?.cleanupFormatting) return 0
|
||||
let res = 0
|
||||
transact(/** @type {Doc} */ (type.doc), transaction => {
|
||||
let start = /** @type {Item} */ (type._start)
|
||||
let end = type._start
|
||||
let startFormats = map.create()
|
||||
const currentFormats = map.copy(startFormats)
|
||||
while (end) {
|
||||
if (end.deleted === false) {
|
||||
switch (end.content.constructor) {
|
||||
case ContentFormat:
|
||||
updateCurrentFormats(currentFormats, /** @type {ContentFormat} */ (end.content))
|
||||
break
|
||||
default:
|
||||
res += cleanupFormattingGap(transaction, start, end, startFormats, currentFormats)
|
||||
startFormats = map.copy(currentFormats)
|
||||
start = end
|
||||
break
|
||||
}
|
||||
}
|
||||
end = end.right
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Transaction>} transactionCleanups
|
||||
* @param {number} i
|
||||
*/
|
||||
const cleanupTransactions = (transactionCleanups, i) => {
|
||||
if (i < transactionCleanups.length) {
|
||||
const transaction = transactionCleanups[i]
|
||||
transaction._done = true
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ds = transaction.deleteSet
|
||||
const mergeStructs = transaction._mergeStructs
|
||||
// insertIntoIdSet(store.ds, ds)
|
||||
try {
|
||||
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||
/**
|
||||
* An array of event callbacks.
|
||||
*
|
||||
* Each callback is called even if the other ones throw errors.
|
||||
*
|
||||
* @type {Array<function():void>}
|
||||
*/
|
||||
const fs = []
|
||||
// observe events on changed types. Deleted types are included: `callTypeObservers` tracks
|
||||
// the event in `changedParentTypes` unconditionally (so changes inside a deleted type — e.g.
|
||||
// a suggestion-deleted tombstone that a custom renderer still renders — bubble to live
|
||||
// ancestors) and decides itself whether the type's own observers fire.
|
||||
transaction.changed.forEach((subs, itemtype) =>
|
||||
fs.push(() => {
|
||||
itemtype._callObserver(transaction, subs)
|
||||
})
|
||||
)
|
||||
fs.push(() => {
|
||||
// deep observe events + the RDT `'delta'` channel. `changedParentTypes` holds the changed
|
||||
// type AND all of its ancestors, so both `observeDeep` and `'delta'` bubble identically.
|
||||
transaction.changedParentTypes.forEach((events, type) => {
|
||||
// We need to think about the possibility that the user transforms the
|
||||
// Y.Doc in the event.
|
||||
// Deleted types are tracked (so changes inside them bubble to live ancestors) but fire
|
||||
// only while something still renders them — i.e. they have a renderer attached.
|
||||
if (type._item !== null && type._item.deleted && type._renderer === null) return
|
||||
const hasDeep = type._dEH.l.length > 0
|
||||
const hasDeltaListeners = (type._observers.get('delta')?.size ?? 0) > 0
|
||||
const maintaining = type._delta !== null
|
||||
if (!hasDeep && !hasDeltaListeners && !maintaining) return
|
||||
/**
|
||||
* @type {YEvent<any>}
|
||||
*/
|
||||
const deepEventHandler = events.find(event => event.target === type) || new YEvent(type, transaction, new Set(null))
|
||||
if (hasDeep) {
|
||||
callEventHandlerListeners(type._dEH, deepEventHandler, transaction)
|
||||
}
|
||||
if (hasDeltaListeners || maintaining) {
|
||||
// the type-rooted deep delta of this transaction (a nested `modify` chain for ancestors)
|
||||
const change = /** @type {any} */ (deepEventHandler.getDelta({ renderer: type._renderer, deep: true }).done())
|
||||
// a base-renderer type doesn't render deleted content, so a change that happened
|
||||
// entirely inside a deleted subtree renders to an empty delta — don't emit those no-ops
|
||||
if (!change.isEmpty()) {
|
||||
type._delta?.apply(change) // keep the cache current (incl. ancestors and diff-renderer attributions) — builder API only, see the YNode._delta fingerprint-memo invariant
|
||||
if (hasDeltaListeners) type.emit('delta', [change, transaction.origin])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
|
||||
callAll(fs, [])
|
||||
if (transaction._needFormattingCleanup && doc.cleanupFormatting) {
|
||||
cleanupYTextAfterTransaction(transaction)
|
||||
}
|
||||
} finally {
|
||||
// Replace deleted items with ItemDeleted / GC.
|
||||
// This is where content is actually remove from the Yjs Doc.
|
||||
if (doc.gc) {
|
||||
tryGcDeleteSet(transaction, ds, doc.gcFilter)
|
||||
}
|
||||
tryMerge(ds, store)
|
||||
|
||||
// on all affected store.clients props, try to merge
|
||||
transaction.insertSet.clients.forEach((ids, client) => {
|
||||
const firstClock = ids.getIds()[0].clock
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
// we iterate from right to left so we can safely remove entries
|
||||
const firstChangePos = math.max(findIndexSS(structs, firstClock), 1)
|
||||
for (let i = structs.length - 1; i >= firstChangePos;) {
|
||||
i -= 1 + tryToMergeWithLefts(structs, i)
|
||||
}
|
||||
})
|
||||
// try to merge mergeStructs
|
||||
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
|
||||
// but at the moment DS does not handle duplicates
|
||||
for (let i = mergeStructs.length - 1; i >= 0; i--) {
|
||||
const { client, clock } = mergeStructs[i].id
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
const replacedStructPos = findIndexSS(structs, clock)
|
||||
if (replacedStructPos + 1 < structs.length) {
|
||||
if (tryToMergeWithLefts(structs, replacedStructPos + 1) > 1) {
|
||||
continue // no need to perform next check, both are already merged
|
||||
}
|
||||
}
|
||||
if (replacedStructPos > 0) {
|
||||
tryToMergeWithLefts(structs, replacedStructPos)
|
||||
}
|
||||
}
|
||||
if (!transaction.local && transaction.insertSet.clients.has(doc.clientID)) {
|
||||
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
|
||||
doc.clientID = generateNewClientId()
|
||||
}
|
||||
// @todo Merge all the transactions into one and provide send the data as a single update message
|
||||
doc.emit('afterTransactionCleanup', [transaction, doc])
|
||||
if (doc._observers.has('update')) {
|
||||
const encoder = new UpdateEncoderV1()
|
||||
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
|
||||
if (hasContent) {
|
||||
doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc, transaction])
|
||||
}
|
||||
}
|
||||
if (doc._observers.has('updateV2')) {
|
||||
const encoder = new UpdateEncoderV2()
|
||||
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
|
||||
if (hasContent) {
|
||||
doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc, transaction])
|
||||
}
|
||||
}
|
||||
const { subdocsAdded, subdocsLoaded, subdocsRemoved } = transaction
|
||||
if (subdocsAdded.size > 0 || subdocsRemoved.size > 0 || subdocsLoaded.size > 0) {
|
||||
subdocsAdded.forEach(subdoc => {
|
||||
subdoc.clientID = doc.clientID
|
||||
if (subdoc.collectionid == null) {
|
||||
subdoc.collectionid = doc.collectionid
|
||||
}
|
||||
doc.subdocs.add(subdoc)
|
||||
})
|
||||
subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc))
|
||||
doc.emit('subdocs', [{ loaded: subdocsLoaded, added: subdocsAdded, removed: subdocsRemoved }, doc, transaction])
|
||||
subdocsRemoved.forEach(subdoc => subdoc.destroy())
|
||||
}
|
||||
|
||||
if (transactionCleanups.length <= i + 1) {
|
||||
doc._transactionCleanups = []
|
||||
doc.emit('afterAllTransactions', [doc, transactionCleanups])
|
||||
} else {
|
||||
cleanupTransactions(transactionCleanups, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This will be called by the transaction once the event handlers are called to potentially cleanup
|
||||
* formats.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
export const cleanupYTextAfterTransaction = transaction => {
|
||||
/**
|
||||
* @type {Set<YNode>}
|
||||
*/
|
||||
const needFullCleanup = new Set()
|
||||
// check if another formatting item was inserted
|
||||
const doc = transaction.doc
|
||||
iterateStructsByIdSet(transaction, transaction.insertSet, (item) => {
|
||||
if (
|
||||
!item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat && item.constructor !== GC
|
||||
) {
|
||||
needFullCleanup.add(/** @type {any} */ (item).parent)
|
||||
}
|
||||
})
|
||||
// cleanup in a new transaction
|
||||
transact(doc, (t) => {
|
||||
iterateStructsByIdSet(transaction, transaction.deleteSet, item => {
|
||||
if (item instanceof GC || !(/** @type {YNode} */ (item.parent)._hasFormatting) || needFullCleanup.has(/** @type {YNode} */ (item.parent))) {
|
||||
return
|
||||
}
|
||||
const parent = /** @type {YNode} */ (item.parent)
|
||||
if (item.content.constructor === ContentFormat) {
|
||||
needFullCleanup.add(parent)
|
||||
} else {
|
||||
// If no format was inserted or deleted, we can make due with contextless
|
||||
// formatting cleanups.
|
||||
// Contextless: it is not necessary to compute currentFormats for the affected position.
|
||||
cleanupContextlessFormattingGap(t, item)
|
||||
}
|
||||
})
|
||||
// If a formatting item was inserted, we simply clean the whole type.
|
||||
// We need to compute currentFormats for the current position anyway.
|
||||
for (const yText of needFullCleanup) {
|
||||
cleanupYTextFormatting(yText)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the functionality of `y.transact(()=>{..})`
|
||||
*
|
||||
* @template T
|
||||
* @param {Doc} doc
|
||||
* @param {function(Transaction):T} f
|
||||
* @param {any} [origin=true]
|
||||
* @param {boolean} [local=true]
|
||||
* @return {T}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const transact = (doc, f, origin = null, local = true) => {
|
||||
const transactionCleanups = doc._transactionCleanups
|
||||
let initialCall = false
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let result = null
|
||||
if (doc._transaction === null) {
|
||||
initialCall = true
|
||||
doc._transaction = new Transaction(doc, origin, local)
|
||||
transactionCleanups.push(doc._transaction)
|
||||
if (transactionCleanups.length === 1) {
|
||||
doc.emit('beforeAllTransactions', [doc])
|
||||
}
|
||||
doc.emit('beforeTransaction', [doc._transaction, doc])
|
||||
}
|
||||
try {
|
||||
result = f(doc._transaction)
|
||||
} finally {
|
||||
if (initialCall) {
|
||||
const finishCleanup = doc._transaction === transactionCleanups[0]
|
||||
doc._transaction = null
|
||||
if (finishCleanup) {
|
||||
// The first transaction ended, now process observer calls.
|
||||
// Observer call may create new transactions for which we need to call the observers and do cleanup.
|
||||
// We don't want to nest these calls, so we execute these calls one after
|
||||
// another.
|
||||
// Also we need to ensure that all cleanups are called, even if the
|
||||
// observes throw errors.
|
||||
// This file is full of hacky try {} finally {} blocks to ensure that an
|
||||
// event can throw errors and also that the cleanup is called.
|
||||
cleanupTransactions(transactionCleanups, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
545
src/utils/UndoManager.js
Normal file
545
src/utils/UndoManager.js
Normal file
@@ -0,0 +1,545 @@
|
||||
import * as time from 'lib0/time'
|
||||
import * as array from 'lib0/array'
|
||||
import * as logging from 'lib0/logging'
|
||||
import { ObservableV2 } from 'lib0/observable'
|
||||
|
||||
import { mergeIdSets, iterateStructsByIdSet, diffIdSet } from './ids.js'
|
||||
import { Item, followRedone } from '../structs/Item.js'
|
||||
|
||||
import { transact } from './Transaction.js'
|
||||
import { createID } from './ID.js'
|
||||
import { isParentOf } from './isParentOf.js'
|
||||
import { getItemCleanStart } from './transaction-helpers.js'
|
||||
import { Doc } from './Doc.js'
|
||||
import { YNode } from '../ynode.js'
|
||||
|
||||
export class StackItem {
|
||||
/**
|
||||
* @param {IdSet} insertions
|
||||
* @param {IdSet} deletions
|
||||
*/
|
||||
constructor (insertions, deletions) {
|
||||
this.inserts = insertions
|
||||
this.deletes = deletions
|
||||
/**
|
||||
* Use this to save and restore metadata like selection range
|
||||
*/
|
||||
this.meta = new Map()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} tr
|
||||
* @param {UndoManager} um
|
||||
* @param {StackItem} stackItem
|
||||
*/
|
||||
const clearUndoManagerStackItem = (tr, um, stackItem) => {
|
||||
iterateStructsByIdSet(tr, stackItem.deletes, item => {
|
||||
if (item instanceof Item && um.scope.some(type => type === tr.doc || isParentOf(/** @type {YNode} */ (type), item))) {
|
||||
keepItem(item, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UndoManager} undoManager
|
||||
* @param {Array<StackItem>} stack
|
||||
* @param {'undo'|'redo'} eventType
|
||||
* @return {StackItem?}
|
||||
*/
|
||||
const popStackItem = (undoManager, stack, eventType) => {
|
||||
/**
|
||||
* Keep a reference to the transaction so we can fire the event with the changedParentTypes
|
||||
* @type {any}
|
||||
*/
|
||||
let _tr = null
|
||||
const doc = undoManager.doc
|
||||
const scope = undoManager.scope
|
||||
transact(doc, transaction => {
|
||||
while (stack.length > 0 && undoManager.currStackItem === null) {
|
||||
const store = doc.store
|
||||
const stackItem = /** @type {StackItem} */ (stack.pop())
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const itemsToRedo = new Set()
|
||||
/**
|
||||
* @type {Array<Item>}
|
||||
*/
|
||||
const itemsToDelete = []
|
||||
let performedChange = false
|
||||
iterateStructsByIdSet(transaction, stackItem.inserts, struct => {
|
||||
if (struct instanceof Item) {
|
||||
if (struct.redone !== null) {
|
||||
let { item, diff } = followRedone(store, struct.id)
|
||||
if (diff > 0) {
|
||||
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
|
||||
}
|
||||
struct = item
|
||||
}
|
||||
if (!struct.deleted && scope.some(type => type === transaction.doc || isParentOf(/** @type {YNode} */ (type), /** @type {Item} */ (struct)))) {
|
||||
itemsToDelete.push(struct)
|
||||
}
|
||||
}
|
||||
})
|
||||
iterateStructsByIdSet(transaction, stackItem.deletes, struct => {
|
||||
if (
|
||||
struct instanceof Item &&
|
||||
scope.some(type => type === transaction.doc || isParentOf(/** @type {YNode} */ (type), struct)) &&
|
||||
// Never redo structs in stackItem.insertions because they were created and deleted in the same capture interval.
|
||||
!stackItem.inserts.hasId(struct.id)
|
||||
) {
|
||||
itemsToRedo.add(struct)
|
||||
}
|
||||
})
|
||||
itemsToRedo.forEach(struct => {
|
||||
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.inserts, undoManager.ignoreRemoteAttributeChanges, undoManager) !== null || performedChange
|
||||
})
|
||||
// We want to delete in reverse order so that children are deleted before
|
||||
// parents, so we have more information available when items are filtered.
|
||||
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
|
||||
const item = itemsToDelete[i]
|
||||
if (undoManager.deleteFilter(item)) {
|
||||
item.delete(transaction)
|
||||
performedChange = true
|
||||
}
|
||||
}
|
||||
undoManager.currStackItem = performedChange ? stackItem : null
|
||||
}
|
||||
transaction.changed.forEach((subProps, type) => {
|
||||
// destroy search marker if necessary
|
||||
if (subProps.has(null) && type._searchMarker) {
|
||||
type._searchMarker.length = 0
|
||||
}
|
||||
})
|
||||
_tr = transaction
|
||||
}, undoManager)
|
||||
const res = undoManager.currStackItem
|
||||
if (res != null) {
|
||||
const changedParentTypes = _tr.changedParentTypes
|
||||
undoManager.emit('stack-item-popped', [{ stackItem: res, type: eventType, changedParentTypes, origin: undoManager }, undoManager])
|
||||
undoManager.currStackItem = null
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UndoManagerOptions
|
||||
* @property {number} [UndoManagerOptions.captureTimeout=500]
|
||||
* @property {function(Transaction):boolean} [UndoManagerOptions.captureTransaction] Do not capture changes of a Transaction if result false.
|
||||
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
|
||||
* it is necessary to filter what an Undo/Redo operation can delete. If this
|
||||
* filter returns false, the type/item won't be deleted even it is in the
|
||||
* undo/redo scope.
|
||||
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
|
||||
* @property {boolean} [ignoreRemoteAttributeChanges] By default, the UndoManager will never overwrite remote changes. In some cases this might be the expected behavior. This property enables overwriting remote changes on attribute changes. (previously named `ignoreRemoteMapChanges`)
|
||||
* @property {Doc} [doc] The document that this UndoManager operates on. Only needed if typeScope is empty.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StackItemEvent
|
||||
* @property {StackItem} StackItemEvent.stackItem
|
||||
* @property {any} StackItemEvent.origin
|
||||
* @property {'undo'|'redo'} StackItemEvent.type
|
||||
* @property {Map<YNode,Array<YEvent<any>>>} StackItemEvent.changedParentTypes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fires 'stack-item-added' event when a stack item was added to either the undo- or
|
||||
* the redo-stack. You may store additional stack information via the
|
||||
* metadata property on `event.stackItem.meta` (it is a `Map` of metadata properties).
|
||||
* Fires 'stack-item-popped' event when a stack item was popped from either the
|
||||
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
|
||||
*
|
||||
* @extends {ObservableV2<{'stack-item-added':function(StackItemEvent, UndoManager):void, 'stack-item-popped': function(StackItemEvent, UndoManager):void, 'stack-cleared': function({ undoStackCleared: boolean, redoStackCleared: boolean }):void, 'stack-item-updated': function(StackItemEvent, UndoManager):void }>}
|
||||
*/
|
||||
export class UndoManager extends ObservableV2 {
|
||||
/**
|
||||
* @param {Doc|YNode|Array<YNode>} typeScope Limits the scope of the UndoManager. If this is set to a ydoc instance, all changes on that ydoc will be undone. If set to a specific type, only changes on that type or its children will be undone. Also accepts an array of types.
|
||||
* @param {UndoManagerOptions} options
|
||||
*/
|
||||
constructor (typeScope, {
|
||||
captureTimeout = 500,
|
||||
captureTransaction = _tr => true,
|
||||
deleteFilter = () => true,
|
||||
trackedOrigins = new Set([null]),
|
||||
ignoreRemoteAttributeChanges = false,
|
||||
doc = /** @type {Doc} */ (array.isArray(typeScope) ? typeScope[0].doc : typeScope instanceof Doc ? typeScope : typeScope.doc)
|
||||
} = {}) {
|
||||
super()
|
||||
/**
|
||||
* @type {Array<YNode | Doc>}
|
||||
*/
|
||||
this.scope = []
|
||||
this.doc = doc
|
||||
this.addToScope(typeScope)
|
||||
this.deleteFilter = deleteFilter
|
||||
trackedOrigins.add(this)
|
||||
this.trackedOrigins = trackedOrigins
|
||||
this.captureTransaction = captureTransaction
|
||||
/**
|
||||
* @type {Array<StackItem>}
|
||||
*/
|
||||
this.undoStack = []
|
||||
/**
|
||||
* @type {Array<StackItem>}
|
||||
*/
|
||||
this.redoStack = []
|
||||
/**
|
||||
* Whether the client is currently undoing (calling UndoManager.undo)
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.undoing = false
|
||||
this.redoing = false
|
||||
/**
|
||||
* The currently popped stack item if UndoManager.undoing or UndoManager.redoing
|
||||
*
|
||||
* @type {StackItem|null}
|
||||
*/
|
||||
this.currStackItem = null
|
||||
this.lastChange = 0
|
||||
this.ignoreRemoteAttributeChanges = ignoreRemoteAttributeChanges
|
||||
this.captureTimeout = captureTimeout
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
this.afterTransactionHandler = transaction => {
|
||||
// Only track certain transactions
|
||||
if (
|
||||
!this.captureTransaction(transaction) ||
|
||||
!this.scope.some(type => transaction.changedParentTypes.has(/** @type {YNode} */ (type)) || type === this.doc) ||
|
||||
(!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))
|
||||
) {
|
||||
return
|
||||
}
|
||||
const undoing = this.undoing
|
||||
const redoing = this.redoing
|
||||
const stack = undoing ? this.redoStack : this.undoStack
|
||||
if (undoing) {
|
||||
this.stopCapturing() // next undo should not be appended to last stack item
|
||||
} else if (!redoing) {
|
||||
// neither undoing nor redoing: delete redoStack
|
||||
this.clear(false, true)
|
||||
}
|
||||
const insertions = transaction.insertSet
|
||||
const now = time.getUnixTime()
|
||||
let didAdd = false
|
||||
if (this.lastChange > 0 && now - this.lastChange < this.captureTimeout && stack.length > 0 && !undoing && !redoing) {
|
||||
// append change to last stack op
|
||||
const lastOp = stack[stack.length - 1]
|
||||
lastOp.deletes = mergeIdSets([lastOp.deletes, transaction.deleteSet])
|
||||
lastOp.inserts = mergeIdSets([lastOp.inserts, insertions])
|
||||
} else {
|
||||
// create a new stack op
|
||||
stack.push(new StackItem(insertions, transaction.deleteSet))
|
||||
didAdd = true
|
||||
}
|
||||
if (!undoing && !redoing) {
|
||||
this.lastChange = now
|
||||
}
|
||||
// make sure that deleted structs are not gc'd
|
||||
iterateStructsByIdSet(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
|
||||
if (item instanceof Item && this.scope.some(type => type === transaction.doc || isParentOf(/** @type {YNode} */ (type), item))) {
|
||||
keepItem(item, true)
|
||||
}
|
||||
})
|
||||
/**
|
||||
* @type {[StackItemEvent, UndoManager]}
|
||||
*/
|
||||
const changeEvent = [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo', changedParentTypes: transaction.changedParentTypes }, this]
|
||||
if (didAdd) {
|
||||
this.emit('stack-item-added', changeEvent)
|
||||
} else {
|
||||
this.emit('stack-item-updated', changeEvent)
|
||||
}
|
||||
}
|
||||
this.destroy = this.destroy.bind(this)
|
||||
this.doc.on('afterTransaction', this.afterTransactionHandler)
|
||||
this.doc.on('destroy', this.destroy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the scope.
|
||||
*
|
||||
* @param {Array<YNode | Doc> | YNode | Doc} ynodes
|
||||
*/
|
||||
addToScope (ynodes) {
|
||||
const tmpSet = new Set(this.scope)
|
||||
ynodes = array.isArray(ynodes) ? ynodes : [ynodes]
|
||||
ynodes.forEach(ynode => {
|
||||
if (!tmpSet.has(ynode)) {
|
||||
tmpSet.add(ynode)
|
||||
if (ynode instanceof YNode ? ynode.doc !== this.doc : ynode !== this.doc) logging.warn('[yjs#509] Not same Y.Doc') // use MultiDocUndoManager instead. also see https://github.com/yjs/yjs/issues/509
|
||||
this.scope.push(ynode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} origin
|
||||
*/
|
||||
addTrackedOrigin (origin) {
|
||||
this.trackedOrigins.add(origin)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} origin
|
||||
*/
|
||||
removeTrackedOrigin (origin) {
|
||||
this.trackedOrigins.delete(origin)
|
||||
}
|
||||
|
||||
clear (clearUndoStack = true, clearRedoStack = true) {
|
||||
if ((clearUndoStack && this.canUndo()) || (clearRedoStack && this.canRedo())) {
|
||||
this.doc.transact(tr => {
|
||||
if (clearUndoStack) {
|
||||
this.undoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
|
||||
this.undoStack = []
|
||||
}
|
||||
if (clearRedoStack) {
|
||||
this.redoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
|
||||
this.redoStack = []
|
||||
}
|
||||
this.emit('stack-cleared', [{ undoStackCleared: clearUndoStack, redoStackCleared: clearRedoStack }])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UndoManager merges Undo-StackItem if they are created within time-gap
|
||||
* smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next
|
||||
* StackItem won't be merged.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* // without stopCapturing
|
||||
* ytext.insert(0, 'a')
|
||||
* ytext.insert(1, 'b')
|
||||
* um.undo()
|
||||
* ytext.toString() // => '' (note that 'ab' was removed)
|
||||
* // with stopCapturing
|
||||
* ytext.insert(0, 'a')
|
||||
* um.stopCapturing()
|
||||
* ytext.insert(0, 'b')
|
||||
* um.undo()
|
||||
* ytext.toString() // => 'a' (note that only 'b' was removed)
|
||||
*
|
||||
*/
|
||||
stopCapturing () {
|
||||
this.lastChange = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo last changes on type.
|
||||
*
|
||||
* @return {StackItem?} Returns StackItem if a change was applied
|
||||
*/
|
||||
undo () {
|
||||
this.undoing = true
|
||||
let res
|
||||
try {
|
||||
res = popStackItem(this, this.undoStack, 'undo')
|
||||
} finally {
|
||||
this.undoing = false
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo last undo operation.
|
||||
*
|
||||
* @return {StackItem?} Returns StackItem if a change was applied
|
||||
*/
|
||||
redo () {
|
||||
this.redoing = true
|
||||
let res
|
||||
try {
|
||||
res = popStackItem(this, this.redoStack, 'redo')
|
||||
} finally {
|
||||
this.redoing = false
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Are undo steps available?
|
||||
*
|
||||
* @return {boolean} `true` if undo is possible
|
||||
*/
|
||||
canUndo () {
|
||||
return this.undoStack.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Are redo steps available?
|
||||
*
|
||||
* @return {boolean} `true` if redo is possible
|
||||
*/
|
||||
canRedo () {
|
||||
return this.redoStack.length > 0
|
||||
}
|
||||
|
||||
destroy () {
|
||||
this.trackedOrigins.delete(this)
|
||||
this.doc.off('afterTransaction', this.afterTransactionHandler)
|
||||
this.doc.off('destroy', this.destroy)
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*
|
||||
* This is not guaranteed to work on documents with gc enabled!
|
||||
*
|
||||
* @param {Doc} ydoc
|
||||
* @param {ContentIds} contentIds
|
||||
* @param {UndoManagerOptions} opts
|
||||
*/
|
||||
export const undoContentIds = (ydoc, contentIds, opts = {}) => {
|
||||
const um = new UndoManager(ydoc, opts)
|
||||
um.undoStack.push(new StackItem(diffIdSet(contentIds.inserts, contentIds.deletes), diffIdSet(contentIds.deletes, contentIds.inserts)))
|
||||
um.undo()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<StackItem>} stack
|
||||
* @param {ID} id
|
||||
*/
|
||||
const isDeletedByUndoStack = (stack, id) => array.some(stack, /** @param {StackItem} s */ s => s.deletes.hasId(id))
|
||||
|
||||
/**
|
||||
* Redoes the effect of this operation.
|
||||
*
|
||||
* @param {Transaction} transaction The Yjs instance.
|
||||
* @param {Item} item
|
||||
* @param {Set<Item>} redoitems
|
||||
* @param {IdSet} itemsToDelete
|
||||
* @param {boolean} ignoreRemoteAttributeChanges
|
||||
* @param {import('../utils/UndoManager.js').UndoManager} um
|
||||
*
|
||||
* @return {Item|null}
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteAttributeChanges, um) => {
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ownClientID = doc.clientID
|
||||
const redone = item.redone
|
||||
if (redone !== null) {
|
||||
return getItemCleanStart(transaction, redone)
|
||||
}
|
||||
let parentItem = /** @type {YNode} */ (item.parent)._item
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let left = null
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let right
|
||||
// make sure that parent is redone
|
||||
if (parentItem !== null && parentItem.deleted === true) {
|
||||
// try to undo parent if it will be undone anyway
|
||||
if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteAttributeChanges, um) === null)) {
|
||||
return null
|
||||
}
|
||||
while (parentItem.redone !== null) {
|
||||
parentItem = getItemCleanStart(transaction, parentItem.redone)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @type {YNode}
|
||||
*/
|
||||
const parentType = /** @type {YNode} */ (parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type)
|
||||
|
||||
if (item.parentSub === null) {
|
||||
// Is an array item. Insert at the old position
|
||||
left = item.left
|
||||
right = item
|
||||
// find next cloned_redo items
|
||||
while (left !== null) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let leftTrace = left
|
||||
// trace redone until parent matches
|
||||
while (leftTrace !== null && /** @type {YNode} */ (leftTrace.parent)._item !== parentItem) {
|
||||
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
|
||||
}
|
||||
if (leftTrace !== null && /** @type {YNode} */ (leftTrace.parent)._item === parentItem) {
|
||||
left = leftTrace
|
||||
break
|
||||
}
|
||||
left = left.left
|
||||
}
|
||||
while (right !== null) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let rightTrace = right
|
||||
// trace redone until parent matches
|
||||
while (rightTrace !== null && /** @type {YNode} */ (rightTrace.parent)._item !== parentItem) {
|
||||
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
|
||||
}
|
||||
if (rightTrace !== null && /** @type {YNode} */ (rightTrace.parent)._item === parentItem) {
|
||||
right = rightTrace
|
||||
break
|
||||
}
|
||||
right = right.right
|
||||
}
|
||||
} else {
|
||||
right = null
|
||||
if (item.right && !ignoreRemoteAttributeChanges) {
|
||||
left = item
|
||||
// Iterate right while right is in itemsToDelete
|
||||
// If it is intended to delete right while item is redone, we can expect that item should replace right.
|
||||
while (left !== null && left.right !== null && (left.right.redone || itemsToDelete.hasId(left.right.id) || isDeletedByUndoStack(um.undoStack, left.right.id) || isDeletedByUndoStack(um.redoStack, left.right.id))) {
|
||||
left = left.right
|
||||
// follow redone
|
||||
while (left.redone) left = getItemCleanStart(transaction, left.redone)
|
||||
}
|
||||
if (left && left.right !== null) {
|
||||
// It is not possible to redo this item because it conflicts with a
|
||||
// change from another client
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
left = parentType._map.get(item.parentSub) || null
|
||||
}
|
||||
if (left !== null && /** @type {YNode} */ (left.parent)._item !== parentItem) {
|
||||
left = parentType._map.get(item.parentSub) || null
|
||||
}
|
||||
}
|
||||
const nextClock = store.getClock(ownClientID)
|
||||
const nextId = createID(ownClientID, nextClock)
|
||||
const redoneItem = new Item(
|
||||
nextId,
|
||||
left, left && left.lastId,
|
||||
right, right && right.id,
|
||||
parentType,
|
||||
item.parentSub,
|
||||
item.content.copy()
|
||||
)
|
||||
item.redone = nextId
|
||||
keepItem(redoneItem, true)
|
||||
redoneItem.integrate(transaction, 0)
|
||||
return redoneItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that neither item nor any of its parents is ever deleted.
|
||||
*
|
||||
* This property does not persist when storing it into a database or when
|
||||
* sending it to other peers
|
||||
*
|
||||
* @param {Item|null} item
|
||||
* @param {boolean} keep
|
||||
*/
|
||||
export const keepItem = (item, keep) => {
|
||||
while (item !== null && item.keep !== keep) {
|
||||
item.keep = keep
|
||||
item = /** @type {YNode} */ (item.parent)._item
|
||||
}
|
||||
}
|
||||
284
src/utils/UpdateDecoder.js
Normal file
284
src/utils/UpdateDecoder.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import * as buffer from 'lib0/buffer'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
|
||||
import { ID, createID } from './ID.js'
|
||||
|
||||
/**
|
||||
* @typedef {IdSetDecoderV1 | IdSetDecoderV2} IdSetDecoder
|
||||
*/
|
||||
|
||||
export class IdSetDecoderV1 {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
*/
|
||||
constructor (decoder) {
|
||||
this.restDecoder = decoder
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
// nop
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsClock () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsLen () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateDecoderV1 extends IdSetDecoderV1 {
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readLeftID () {
|
||||
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readRightID () {
|
||||
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next client id.
|
||||
* Use this in favor of readID whenever possible to reduce the number of objects created.
|
||||
*/
|
||||
readClient () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readInfo () {
|
||||
return decoding.readUint8(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readString () {
|
||||
return decoding.readVarString(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} isKey
|
||||
*/
|
||||
readParentInfo () {
|
||||
return decoding.readVarUint(this.restDecoder) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readTypeRef () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @return {number} len
|
||||
*/
|
||||
readLen () {
|
||||
return decoding.readVarUint(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {any}
|
||||
*/
|
||||
readAny () {
|
||||
return decoding.readAny(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
readBuf () {
|
||||
return buffer.copyUint8Array(decoding.readVarUint8Array(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy implementation uses JSON parse. We use any-decoding in v2.
|
||||
*
|
||||
* @return {any}
|
||||
*/
|
||||
readJSON () {
|
||||
return JSON.parse(decoding.readVarString(this.restDecoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readKey () {
|
||||
return decoding.readVarString(this.restDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
export class IdSetDecoderV2 {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
*/
|
||||
constructor (decoder) {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
this.dsCurrVal = 0
|
||||
this.restDecoder = decoder
|
||||
}
|
||||
|
||||
resetDsCurVal () {
|
||||
this.dsCurrVal = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsClock () {
|
||||
this.dsCurrVal += decoding.readVarUint(this.restDecoder)
|
||||
return this.dsCurrVal
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
readDsLen () {
|
||||
const diff = decoding.readVarUint(this.restDecoder) + 1
|
||||
this.dsCurrVal += diff
|
||||
return diff
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateDecoderV2 extends IdSetDecoderV2 {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
*/
|
||||
constructor (decoder) {
|
||||
super(decoder)
|
||||
/**
|
||||
* List of cached keys. If the keys[id] does not exist, we read a new key
|
||||
* from stringEncoder and push it to keys.
|
||||
*
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
this.keys = []
|
||||
decoding.readVarUint(decoder) // read feature flag - currently unused
|
||||
this.keyClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.clientDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.leftClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.rightClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.infoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
|
||||
this.stringDecoder = new decoding.StringDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.parentInfoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
|
||||
this.typeRefDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
this.lenDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readLeftID () {
|
||||
return new ID(this.clientDecoder.read(), this.leftClockDecoder.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ID}
|
||||
*/
|
||||
readRightID () {
|
||||
return new ID(this.clientDecoder.read(), this.rightClockDecoder.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next client id.
|
||||
* Use this in favor of readID whenever possible to reduce the number of objects created.
|
||||
*/
|
||||
readClient () {
|
||||
return this.clientDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
readInfo () {
|
||||
return /** @type {number} */ (this.infoDecoder.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readString () {
|
||||
return this.stringDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
readParentInfo () {
|
||||
return this.parentInfoDecoder.read() === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number} An unsigned 8-bit integer
|
||||
*/
|
||||
readTypeRef () {
|
||||
return this.typeRefDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
readLen () {
|
||||
return this.lenDecoder.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {any}
|
||||
*/
|
||||
readAny () {
|
||||
return decoding.readAny(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
readBuf () {
|
||||
return decoding.readVarUint8Array(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is mainly here for legacy purposes.
|
||||
*
|
||||
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
|
||||
*
|
||||
* @return {any}
|
||||
*/
|
||||
readJSON () {
|
||||
return decoding.readAny(this.restDecoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
readKey () {
|
||||
const keyClock = this.keyClockDecoder.read()
|
||||
if (keyClock < this.keys.length) {
|
||||
return this.keys[keyClock]
|
||||
} else {
|
||||
const key = this.stringDecoder.read()
|
||||
this.keys.push(key)
|
||||
return key
|
||||
}
|
||||
}
|
||||
}
|
||||
323
src/utils/UpdateEncoder.js
Normal file
323
src/utils/UpdateEncoder.js
Normal file
@@ -0,0 +1,323 @@
|
||||
import * as error from 'lib0/error'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
|
||||
export class IdSetEncoderV1 {
|
||||
constructor () {
|
||||
this.restEncoder = encoding.createEncoder()
|
||||
}
|
||||
|
||||
toUint8Array () {
|
||||
return encoding.toUint8Array(this.restEncoder)
|
||||
}
|
||||
|
||||
resetIdSetCurVal () {
|
||||
// nop
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} clock
|
||||
*/
|
||||
writeIdSetClock (clock) {
|
||||
encoding.writeVarUint(this.restEncoder, clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} len
|
||||
*/
|
||||
writeIdSetLen (len) {
|
||||
encoding.writeVarUint(this.restEncoder, len)
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateEncoderV1 extends IdSetEncoderV1 {
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeLeftID (id) {
|
||||
encoding.writeVarUint(this.restEncoder, id.client)
|
||||
encoding.writeVarUint(this.restEncoder, id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeRightID (id) {
|
||||
encoding.writeVarUint(this.restEncoder, id.client)
|
||||
encoding.writeVarUint(this.restEncoder, id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use writeClient and writeClock instead of writeID if possible.
|
||||
* @param {number} client
|
||||
*/
|
||||
writeClient (client) {
|
||||
encoding.writeVarUint(this.restEncoder, client)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeInfo (info) {
|
||||
encoding.writeUint8(this.restEncoder, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
writeString (s) {
|
||||
encoding.writeVarString(this.restEncoder, s)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isYKey
|
||||
*/
|
||||
writeParentInfo (isYKey) {
|
||||
encoding.writeVarUint(this.restEncoder, isYKey ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeTypeRef (info) {
|
||||
encoding.writeVarUint(this.restEncoder, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @param {number} len
|
||||
*/
|
||||
writeLen (len) {
|
||||
encoding.writeVarUint(this.restEncoder, len)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} any
|
||||
*/
|
||||
writeAny (any) {
|
||||
encoding.writeAny(this.restEncoder, any)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
*/
|
||||
writeBuf (buf) {
|
||||
encoding.writeVarUint8Array(this.restEncoder, buf)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} embed
|
||||
*/
|
||||
writeJSON (embed) {
|
||||
encoding.writeVarString(this.restEncoder, JSON.stringify(embed))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
writeKey (key) {
|
||||
encoding.writeVarString(this.restEncoder, key)
|
||||
}
|
||||
}
|
||||
|
||||
export class IdSetEncoderV2 {
|
||||
constructor () {
|
||||
this.restEncoder = encoding.createEncoder() // encodes all the rest / non-optimized
|
||||
this.dsCurrVal = 0
|
||||
}
|
||||
|
||||
toUint8Array () {
|
||||
return encoding.toUint8Array(this.restEncoder)
|
||||
}
|
||||
|
||||
resetIdSetCurVal () {
|
||||
this.dsCurrVal = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} clock
|
||||
*/
|
||||
writeIdSetClock (clock) {
|
||||
const diff = clock - this.dsCurrVal
|
||||
this.dsCurrVal = clock
|
||||
encoding.writeVarUint(this.restEncoder, diff)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} len
|
||||
*/
|
||||
writeIdSetLen (len) {
|
||||
if (len === 0) {
|
||||
error.unexpectedCase()
|
||||
}
|
||||
encoding.writeVarUint(this.restEncoder, len - 1)
|
||||
this.dsCurrVal += len
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateEncoderV2 extends IdSetEncoderV2 {
|
||||
constructor () {
|
||||
super()
|
||||
/**
|
||||
* @type {Map<string,number>}
|
||||
*/
|
||||
this.keyMap = new Map()
|
||||
/**
|
||||
* Refers to the next unique key-identifier to me used.
|
||||
* See writeKey method for more information.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
this.keyClock = 0
|
||||
this.keyClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||
this.clientEncoder = new encoding.UintOptRleEncoder()
|
||||
this.leftClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||
this.rightClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||
this.infoEncoder = new encoding.RleEncoder(encoding.writeUint8)
|
||||
this.stringEncoder = new encoding.StringEncoder()
|
||||
this.parentInfoEncoder = new encoding.RleEncoder(encoding.writeUint8)
|
||||
this.typeRefEncoder = new encoding.UintOptRleEncoder()
|
||||
this.lenEncoder = new encoding.UintOptRleEncoder()
|
||||
}
|
||||
|
||||
toUint8Array () {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, 0) // this is a feature flag that we might use in the future
|
||||
encoding.writeVarUint8Array(encoder, this.keyClockEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.clientEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.leftClockEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.rightClockEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.infoEncoder))
|
||||
encoding.writeVarUint8Array(encoder, this.stringEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.parentInfoEncoder))
|
||||
encoding.writeVarUint8Array(encoder, this.typeRefEncoder.toUint8Array())
|
||||
encoding.writeVarUint8Array(encoder, this.lenEncoder.toUint8Array())
|
||||
// @note The rest encoder is appended! (note the missing var)
|
||||
encoding.writeUint8Array(encoder, encoding.toUint8Array(this.restEncoder))
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeLeftID (id) {
|
||||
this.clientEncoder.write(id.client)
|
||||
this.leftClockEncoder.write(id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
writeRightID (id) {
|
||||
this.clientEncoder.write(id.client)
|
||||
this.rightClockEncoder.write(id.clock)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} client
|
||||
*/
|
||||
writeClient (client) {
|
||||
this.clientEncoder.write(client)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeInfo (info) {
|
||||
this.infoEncoder.write(info)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
writeString (s) {
|
||||
this.stringEncoder.write(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isYKey
|
||||
*/
|
||||
writeParentInfo (isYKey) {
|
||||
this.parentInfoEncoder.write(isYKey ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} info An unsigned 8-bit integer
|
||||
*/
|
||||
writeTypeRef (info) {
|
||||
this.typeRefEncoder.write(info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write len of a struct - well suited for Opt RLE encoder.
|
||||
*
|
||||
* @param {number} len
|
||||
*/
|
||||
writeLen (len) {
|
||||
this.lenEncoder.write(len)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} any
|
||||
*/
|
||||
writeAny (any) {
|
||||
encoding.writeAny(this.restEncoder, any)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
*/
|
||||
writeBuf (buf) {
|
||||
encoding.writeVarUint8Array(this.restEncoder, buf)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is mainly here for legacy purposes.
|
||||
*
|
||||
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
|
||||
*
|
||||
* @param {any} embed
|
||||
*/
|
||||
writeJSON (embed) {
|
||||
encoding.writeAny(this.restEncoder, embed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Property keys are often reused. For example, in y-prosemirror the key `bold` might
|
||||
* occur very often. For a 3d application, the key `position` might occur very often.
|
||||
*
|
||||
* We cache these keys in a Map and refer to them via a unique number.
|
||||
*
|
||||
* @param {string} key
|
||||
*/
|
||||
writeKey (key) {
|
||||
const clock = this.keyMap.get(key)
|
||||
if (clock === undefined) {
|
||||
/**
|
||||
* @todo uncomment to introduce this feature finally
|
||||
*
|
||||
* Background. The ContentFormat object was always encoded using writeKey, but the decoder used to use readString.
|
||||
* Furthermore, I forgot to set the keyclock. So everything was working fine.
|
||||
*
|
||||
* However, this feature here is basically useless as it is not being used (it actually only consumes extra memory).
|
||||
*
|
||||
* I don't know yet how to reintroduce this feature..
|
||||
*
|
||||
* Older clients won't be able to read updates when we reintroduce this feature. So this should probably be done using a flag.
|
||||
*
|
||||
*/
|
||||
// this.keyMap.set(key, this.keyClock)
|
||||
this.keyClockEncoder.write(this.keyClock++)
|
||||
this.stringEncoder.write(key)
|
||||
} else {
|
||||
this.keyClockEncoder.write(clock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {IdSetEncoderV1 | IdSetEncoderV2} IdSetEncoder
|
||||
*/
|
||||
/**
|
||||
* @typedef {UpdateEncoderV1 | UpdateEncoderV2} UpdateEncoder
|
||||
*/
|
||||
194
src/utils/YEvent.js
Normal file
194
src/utils/YEvent.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import * as map from 'lib0/map'
|
||||
import * as set from 'lib0/set'
|
||||
|
||||
import { diffIdSet, intersectSets, mergeIdSets } from './ids.js'
|
||||
import { createAbsolutePositionFromRelativePosition, createRelativePosition } from './RelativePosition.js'
|
||||
|
||||
/**
|
||||
* @template {DeltaConf} DConf
|
||||
* YEvent describes the changes on a YNode.
|
||||
*/
|
||||
export class YEvent {
|
||||
/**
|
||||
* @param {YNode<DConf>} target The changed type.
|
||||
* @param {import('./Transaction.js').Transaction} transaction
|
||||
* @param {Set<any>?} subs The keys that changed
|
||||
*/
|
||||
constructor (target, transaction, subs) {
|
||||
/**
|
||||
* The type on which this event was created on.
|
||||
* @type {import('../ynode.js').YNode<DConf>}
|
||||
*/
|
||||
this.target = target
|
||||
/**
|
||||
* The current target on which the observe callback is called.
|
||||
* @type {YNode<any>}
|
||||
*/
|
||||
this.currentTarget = target
|
||||
/**
|
||||
* The transaction that triggered this event.
|
||||
* @type {import('./Transaction.js').Transaction}
|
||||
*/
|
||||
this.transaction = transaction
|
||||
/**
|
||||
* @type {Delta<import('../ynode.js').DeltaConfDeltaToYNode<DConf>>|null}
|
||||
*/
|
||||
this._delta = null
|
||||
/**
|
||||
* @type {Delta<DConf>|null}
|
||||
*/
|
||||
this._deltaDeep = null
|
||||
/**
|
||||
* Whether the children changed.
|
||||
* @type {Boolean}
|
||||
* @private
|
||||
*/
|
||||
this.childListChanged = false
|
||||
/**
|
||||
* Set of all changed attributes.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
this.keysChanged = new Set()
|
||||
subs?.forEach((sub) => {
|
||||
if (sub === null) {
|
||||
this.childListChanged = true
|
||||
} else {
|
||||
this.keysChanged.add(sub)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a struct is deleted by this event.
|
||||
*
|
||||
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
|
||||
*
|
||||
* @param {AbstractStruct} struct
|
||||
* @return {boolean}
|
||||
*/
|
||||
deletes (struct) {
|
||||
return this.transaction.deleteSet.hasId(struct.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a struct is added by this event.
|
||||
*
|
||||
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
|
||||
*
|
||||
* @param {AbstractStruct} struct
|
||||
* @return {boolean}
|
||||
*/
|
||||
adds (struct) {
|
||||
return this.transaction.insertSet.hasId(struct.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {boolean} [Deep=false]
|
||||
* @param {object} [opts]
|
||||
* @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to the target type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed
|
||||
* @param {Deep} [opts.deep]
|
||||
* @return {Deep extends true ? Delta<DConf> : Delta<import('../ynode.js').DeltaConfDeltaToYNode<DConf>>} The Delta representation of this type.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
getDelta ({ renderer = this.target._renderer, deep } = {}) {
|
||||
const insertSet = this.transaction.insertSet
|
||||
const deleteSet = this.transaction.deleteSet
|
||||
// content that was both inserted AND deleted by this transaction is normally invisible — but a
|
||||
// renderer may still render it (e.g. remote content integrated under a suggestion-deleted
|
||||
// parent is auto-deleted yet rendered with a delete attribution). Render
|
||||
// `(I ∪ D) − ((I ∩ D) − renderer.attributed)`: the symmetric difference plus the attributed
|
||||
// part of the intersection.
|
||||
const both = intersectSets(insertSet, deleteSet)
|
||||
const itemsToRender = mergeIdSets(
|
||||
(both.isEmpty() || renderer === null)
|
||||
? [diffIdSet(insertSet, deleteSet), diffIdSet(deleteSet, insertSet)]
|
||||
: [diffIdSet(insertSet, deleteSet), diffIdSet(deleteSet, insertSet), intersectSets(both, renderer.attributed)]
|
||||
)
|
||||
/**
|
||||
* @todo this should be done only one in the transaction step
|
||||
*
|
||||
* @type {Map<YNode,Set<string|null>>|null}
|
||||
*/
|
||||
let modified = this.transaction.changed
|
||||
if (deep) {
|
||||
// need to add deep changes to copy of modified
|
||||
const dchanged = new Map()
|
||||
modified.forEach((attrs, type) => {
|
||||
dchanged.set(type, new Set(attrs))
|
||||
})
|
||||
for (let m of modified.keys()) {
|
||||
while (m._item != null) {
|
||||
const item = m._item
|
||||
const ms = map.setIfUndefined(dchanged, item?.parent, set.create)
|
||||
if (item && !ms.has(item.parentSub)) {
|
||||
ms.add(item.parentSub)
|
||||
m = /** @type {any} */ (item.parent)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
modified = dchanged
|
||||
}
|
||||
return /** @type {any} */ (this.target.toDelta({ renderer, itemsToRender, retainDeletes: true, insertedItems: insertSet, deep: !!deep, modified }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the changes in the delta format.
|
||||
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
|
||||
*
|
||||
* @type {Delta<import('../ynode.js').DeltaConfDeltaToYNode<DConf>>} The Delta representation of this type.
|
||||
* @public
|
||||
*/
|
||||
get delta () {
|
||||
return /** @type {any} */ (this._delta ?? (this._delta = this.getDelta().done()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the changes in the delta format.
|
||||
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
|
||||
*
|
||||
* @type {Delta<DConf>} The Delta representation of this type.
|
||||
* @public
|
||||
*/
|
||||
get deltaDeep () {
|
||||
return /** @type {any} */ (this._deltaDeep ?? (this._deltaDeep = /** @type {any} */ (this.getDelta({ deep: true }))))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the path from this type to the specified target.
|
||||
*
|
||||
* @example
|
||||
* // `child` should be accessible via `type.get(path[0]).get(path[1])..`
|
||||
* const path = type.getPathTo(child)
|
||||
* // assuming `type instanceof YArray`
|
||||
* console.log(path) // might look like => [2, 'key1']
|
||||
* child === type.get(path[0]).get(path[1])
|
||||
*
|
||||
* @param {YNode} parent
|
||||
* @param {YNode} child target
|
||||
* @param {AbstractRenderer?} renderer
|
||||
* @return {Array<string|number>} Path to the target
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const getPathTo = (parent, child, renderer = null) => {
|
||||
const path = []
|
||||
const doc = /** @type {Doc} */ (parent.doc)
|
||||
while (child._item !== null && child !== parent) {
|
||||
if (child._item.parentSub !== null) {
|
||||
// parent is map-ish
|
||||
path.unshift(child._item.parentSub)
|
||||
} else {
|
||||
const parent = /** @type {import('../ynode.js').YNode} */ (child._item.parent)
|
||||
// parent is array-ish
|
||||
const apos = /** @type {import('../utils/RelativePosition.js').AbsolutePosition} */ (createAbsolutePositionFromRelativePosition(createRelativePosition(parent, child._item.id), doc, false, renderer))
|
||||
path.unshift(apos.index)
|
||||
}
|
||||
child = /** @type {YNode} */ (child._item.parent)
|
||||
}
|
||||
return path
|
||||
}
|
||||
0
src/utils/content-helper.js
Normal file
0
src/utils/content-helper.js
Normal file
32
src/utils/delta-helpers.js
Normal file
32
src/utils/delta-helpers.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as delta from 'lib0/delta'
|
||||
import { createInsertSetFromStructStore, createDeleteSetFromStructStore, diffIdSet, mergeIdSets } from './ids.js'
|
||||
import { createDiffRenderer } from './Renderer.js'
|
||||
import { computeModifiedFromItems } from '../ynode.js'
|
||||
|
||||
/**
|
||||
* @param {Doc} v1
|
||||
* @param {Doc} v2
|
||||
* @return {delta.DeltaBuilderAny}
|
||||
*/
|
||||
export const diffDocsToDelta = (v1, v2, { renderer = createDiffRenderer(v1, v2) } = {}) => {
|
||||
const insertDiff = diffIdSet(createInsertSetFromStructStore(v2.store, false), createInsertSetFromStructStore(v1.store, false))
|
||||
const deleteDiff = diffIdSet(createDeleteSetFromStructStore(v2.store), createDeleteSetFromStructStore(v1.store))
|
||||
// don't render items that have been inserted and then deleted
|
||||
const insertsOnly = diffIdSet(insertDiff, deleteDiff)
|
||||
const itemsToRender = mergeIdSets([insertsOnly, deleteDiff])
|
||||
/**
|
||||
* @type {Map<YNode, Set<string|null>>}
|
||||
*/
|
||||
const changedTypes = computeModifiedFromItems(v2.store, itemsToRender)
|
||||
const d = delta.create()
|
||||
v2.share.forEach((type, typename) => {
|
||||
const typeConf = changedTypes.get(type)
|
||||
if (typeConf) {
|
||||
const shareDelta = type.toDelta({
|
||||
renderer, itemsToRender, retainDeletes: true, modified: changedTypes, deep: true
|
||||
})
|
||||
d.modifyAttr(typename, shareDelta)
|
||||
}
|
||||
})
|
||||
return d
|
||||
}
|
||||
110
src/utils/encoding-helpers.js
Normal file
110
src/utils/encoding-helpers.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as math from 'lib0/math'
|
||||
import * as array from 'lib0/array'
|
||||
|
||||
import { findIndexSS } from './transaction-helpers.js'
|
||||
import { Skip } from '../structs/Skip.js'
|
||||
import { createID } from './ID.js'
|
||||
import { writeIdSet } from './ids.js'
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {Array<GC|Item|Skip>} structs All structs by `client`
|
||||
* @param {number} client
|
||||
* @param {Array<IdRange>} idranges
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const writeStructs = (encoder, structs, client, idranges) => {
|
||||
let structsToWrite = 0 // this accounts for the skips
|
||||
/**
|
||||
* @type {Array<{ start: number, end: number, startClock: number, endClock: number }>}
|
||||
*/
|
||||
const indexRanges = []
|
||||
const firstPossibleClock = structs[0].id.clock
|
||||
const lastStruct = array.last(structs)
|
||||
const lastPossibleClock = lastStruct.id.clock + lastStruct.length
|
||||
idranges.forEach(idrange => {
|
||||
const startClock = math.max(idrange.clock, firstPossibleClock)
|
||||
const endClock = math.min(idrange.clock + idrange.len, lastPossibleClock)
|
||||
if (startClock >= endClock) return // structs for this range do not exist
|
||||
// inclusive start
|
||||
const start = findIndexSS(structs, startClock)
|
||||
// exclusive end
|
||||
const end = findIndexSS(structs, endClock - 1) + 1
|
||||
structsToWrite += end - start
|
||||
indexRanges.push({
|
||||
start,
|
||||
end,
|
||||
startClock,
|
||||
endClock
|
||||
})
|
||||
})
|
||||
structsToWrite += idranges.length - 1
|
||||
// start writing with this clock. this is updated to the next clock that we expect to write
|
||||
let clock = indexRanges[0].startClock
|
||||
// write # encoded structs
|
||||
encoding.writeVarUint(encoder.restEncoder, structsToWrite)
|
||||
encoder.writeClient(client)
|
||||
// write clock
|
||||
encoding.writeVarUint(encoder.restEncoder, clock)
|
||||
indexRanges.forEach(indexRange => {
|
||||
const skipLen = indexRange.startClock - clock
|
||||
if (skipLen > 0) {
|
||||
new Skip(createID(client, clock), skipLen).write(encoder, 0)
|
||||
clock += skipLen
|
||||
}
|
||||
for (let i = indexRange.start; i < indexRange.end; i++) {
|
||||
const struct = structs[i]
|
||||
const structEnd = struct.id.clock + struct.length
|
||||
const offsetEnd = math.max(structEnd - indexRange.endClock, 0)
|
||||
struct.write(encoder, clock - struct.id.clock, offsetEnd)
|
||||
clock = structEnd - offsetEnd
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {StructStore} store
|
||||
* @param {IdSet} idset
|
||||
*
|
||||
* @todo at the moment this writes the full deleteset range
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const writeStructsFromIdSet = (encoder, store, idset) => {
|
||||
// write # states that were updated
|
||||
encoding.writeVarUint(encoder.restEncoder, idset.clients.size)
|
||||
// Write items with higher client ids first
|
||||
// This heavily improves the conflict algorithm.
|
||||
array.from(idset.clients.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, ids]) => {
|
||||
const idRanges = ids.getIds()
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
writeStructs(encoder, structs, client, idRanges)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {Transaction} transaction
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const writeStructsFromTransaction = (encoder, transaction) => writeStructsFromIdSet(encoder, transaction.doc.store, transaction.insertSet)
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {Transaction} transaction
|
||||
* @return {boolean} Whether data was written.
|
||||
*/
|
||||
export const writeUpdateMessageFromTransaction = (encoder, transaction) => {
|
||||
if (transaction.deleteSet.clients.size === 0 && transaction.insertSet.clients.size === 0) {
|
||||
return false
|
||||
}
|
||||
writeStructsFromTransaction(encoder, transaction)
|
||||
writeIdSet(encoder, transaction.deleteSet)
|
||||
return true
|
||||
}
|
||||
697
src/utils/encoding.js
Normal file
697
src/utils/encoding.js
Normal file
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* @module encoding
|
||||
*/
|
||||
/*
|
||||
* We use the first five bits in the info flag for determining the type of the struct.
|
||||
*
|
||||
* 0: GC
|
||||
* 1: Item with Deleted content
|
||||
* 2: Item with JSON content
|
||||
* 3: Item with Binary content
|
||||
* 4: Item with String content
|
||||
* 5: Item with Embed content (for richtext content)
|
||||
* 6: Item with Format content (a formatting marker for richtext content)
|
||||
* 7: Item with Type
|
||||
*/
|
||||
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as map from 'lib0/map'
|
||||
import * as math from 'lib0/math'
|
||||
import * as array from 'lib0/array'
|
||||
|
||||
import { getStateVector, StructStore } from './StructStore.js'
|
||||
import { getItemCleanStart, getItemCleanEnd } from './transaction-helpers.js'
|
||||
import { createIdSet, IdRange, readAndApplyDeleteSet, readIdSet, mergeIdSets, writeIdSet } from './ids.js'
|
||||
import { createID, ID } from './ID.js'
|
||||
import { UpdateDecoderV1, UpdateDecoderV2, IdSetDecoderV1 } from './UpdateDecoder.js'
|
||||
import { UpdateEncoderV1, UpdateEncoderV2, IdSetEncoderV1, IdSetEncoderV2 } from './UpdateEncoder.js'
|
||||
import { convertUpdateFormatV2ToV1, LazyStructReader, LazyStructWriter, writeStructToLazyStructWriter, finishLazyStructWriting } from './updates.js'
|
||||
import { readBlockSet, writeBlockSet } from './BlockSet.js'
|
||||
import { Skip } from '../structs/Skip.js'
|
||||
import { Item } from '../structs/Item.js'
|
||||
import { GC } from '../structs/GC.js'
|
||||
import { Doc } from './Doc.js'
|
||||
import { writeStructs } from './encoding-helpers.js'
|
||||
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {StructStore} store
|
||||
* @param {Map<number,number>} _sm
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const writeClientsStructs = (encoder, store, _sm) => {
|
||||
// we filter all valid _sm entries into sm
|
||||
const sm = new Map()
|
||||
_sm.forEach((clock, client) => {
|
||||
// only write if new structs are available
|
||||
if (store.getClock(client) > clock) {
|
||||
sm.set(client, clock)
|
||||
}
|
||||
})
|
||||
getStateVector(store).forEach((_clock, client) => {
|
||||
if (!_sm.has(client)) {
|
||||
sm.set(client, 0)
|
||||
}
|
||||
})
|
||||
// write # states that were updated
|
||||
encoding.writeVarUint(encoder.restEncoder, sm.size)
|
||||
// Write items with higher client ids first
|
||||
// This heavily improves the conflict algorithm.
|
||||
array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
const lastStruct = structs[structs.length - 1]
|
||||
writeStructs(encoder, structs, client, [new IdRange(clock, lastStruct.id.clock + lastStruct.length - clock)])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume computing structs generated by struct readers.
|
||||
*
|
||||
* While there is something to do, we integrate structs in this order
|
||||
* 1. top element on stack, if stack is not empty
|
||||
* 2. next element from current struct reader (if empty, use next struct reader)
|
||||
*
|
||||
* If struct causally depends on another struct (ref.missing), we put next reader of
|
||||
* `ref.id.client` on top of stack.
|
||||
*
|
||||
* At some point we find a struct that has no causal dependencies,
|
||||
* then we start emptying the stack.
|
||||
*
|
||||
* It is not possible to have circles: i.e. struct1 (from client1) depends on struct2 (from client2)
|
||||
* depends on struct3 (from client1). Therefore the max stack size is equal to `structReaders.length`.
|
||||
*
|
||||
* This method is implemented in a way so that we can resume computation if this update
|
||||
* causally depends on another update.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {BlockSet} clientsStructRefs
|
||||
* @return { null | { update: Uint8Array<ArrayBuffer>, missing: Map<number,number> } }
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const integrateStructs = (transaction, store, clientsStructRefs) => {
|
||||
/**
|
||||
* @type {Array<Item | GC | Skip>}
|
||||
*/
|
||||
const stack = []
|
||||
// sort them so that we take the higher id first, in case of conflicts the lower id will probably not conflict with the id from the higher user.
|
||||
let clientsStructRefsIds = array.from(clientsStructRefs.clients.keys()).sort((a, b) => a - b)
|
||||
if (clientsStructRefsIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
const getNextStructTarget = () => {
|
||||
if (clientsStructRefsIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
let nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.clients.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
|
||||
while (nextStructsTarget.refs.length === nextStructsTarget.i) {
|
||||
clientsStructRefsIds.pop()
|
||||
if (clientsStructRefsIds.length > 0) {
|
||||
nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.clients.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return nextStructsTarget
|
||||
}
|
||||
let curStructsTarget = getNextStructTarget()
|
||||
if (curStructsTarget === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {StructStore}
|
||||
*/
|
||||
const restStructs = new StructStore()
|
||||
const missingSV = new Map()
|
||||
/**
|
||||
* @param {number} client
|
||||
* @param {number} clock
|
||||
*/
|
||||
const updateMissingSv = (client, clock) => {
|
||||
const mclock = missingSV.get(client)
|
||||
if (mclock == null || mclock > clock) {
|
||||
missingSV.set(client, clock)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @type {GC|Item|Skip}
|
||||
*/
|
||||
let stackHead = /** @type {any} */ (curStructsTarget).refs[/** @type {any} */ (curStructsTarget).i++]
|
||||
// caching the state because it is used very often
|
||||
const state = new Map()
|
||||
|
||||
// // caching the state because it is used very often
|
||||
// const currentInsertSet = createIdSet()
|
||||
// clientsStructRefsIds.forEach(clientId => {
|
||||
// currentInsertSet.clients.set(clientid, new IdRanges(_createInsertSliceFromStructs(store.clients.get(clientId) ?? [], false)))
|
||||
// })
|
||||
|
||||
const addStackToRestSS = () => {
|
||||
for (const item of stack) {
|
||||
const client = item.id.client
|
||||
const inapplicableItems = clientsStructRefs.clients.get(client)
|
||||
if (inapplicableItems) {
|
||||
// decrement because we weren't able to apply previous operation
|
||||
inapplicableItems.i--
|
||||
restStructs.clients.set(client, inapplicableItems.refs.slice(inapplicableItems.i))
|
||||
clientsStructRefs.clients.delete(client)
|
||||
inapplicableItems.i = 0
|
||||
inapplicableItems.refs = []
|
||||
} else {
|
||||
// item was the last item on clientsStructRefs and the field was already cleared. Add item to restStructs and continue
|
||||
restStructs.clients.set(client, [item])
|
||||
}
|
||||
// remove client from clientsStructRefsIds to prevent users from applying the same update again
|
||||
clientsStructRefsIds = clientsStructRefsIds.filter(c => c !== client)
|
||||
}
|
||||
stack.length = 0
|
||||
}
|
||||
|
||||
// iterate over all struct readers until we are done
|
||||
while (true) {
|
||||
if (stackHead.constructor !== Skip) {
|
||||
const localClock = map.setIfUndefined(state, stackHead.id.client, () => store.getClock(stackHead.id.client))
|
||||
const offset = localClock - stackHead.id.clock
|
||||
const missing = getMissing(/** @type {any} */ (stackHead), transaction, store)
|
||||
if (missing !== null) {
|
||||
stack.push(stackHead)
|
||||
// get the struct reader that has the missing struct
|
||||
/**
|
||||
* @type {{ refs: Array<GC|Item|Skip>, i: number }}
|
||||
*/
|
||||
const structRefs = clientsStructRefs.clients.get(/** @type {number} */ (missing)) || { refs: [], i: 0 }
|
||||
if (structRefs.refs.length === structRefs.i || missing === stackHead.id.client || stack.some(s => s.id.client === missing)) { // @todo this could be optimized!
|
||||
// This update message causally depends on another update message that doesn't exist yet
|
||||
updateMissingSv(/** @type {number} */ (missing), store.getClock(missing))
|
||||
addStackToRestSS()
|
||||
} else {
|
||||
stackHead = structRefs.refs[structRefs.i++]
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// all fine, apply the stackhead
|
||||
// but first add a skip to structs if necessary
|
||||
if (offset < 0) {
|
||||
const skip = new Skip(createID(stackHead.id.client, localClock), -offset)
|
||||
skip.integrate(transaction, 0)
|
||||
}
|
||||
stackHead.integrate(transaction, 0)
|
||||
state.set(stackHead.id.client, math.max(stackHead.id.clock + stackHead.length, localClock))
|
||||
}
|
||||
}
|
||||
// iterate to next stackHead
|
||||
if (stack.length > 0) {
|
||||
stackHead = /** @type {GC|Item} */ (stack.pop())
|
||||
} else if (curStructsTarget !== null && curStructsTarget.i < curStructsTarget.refs.length) {
|
||||
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
|
||||
} else {
|
||||
curStructsTarget = getNextStructTarget()
|
||||
if (curStructsTarget === null) {
|
||||
// we are done!
|
||||
break
|
||||
} else {
|
||||
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
|
||||
}
|
||||
}
|
||||
}
|
||||
if (restStructs.clients.size > 0) {
|
||||
const encoder = new UpdateEncoderV2()
|
||||
writeClientsStructs(encoder, restStructs, new Map())
|
||||
// write empty deleteset
|
||||
// writeDeleteSet(encoder, new DeleteSet())
|
||||
encoding.writeVarUint(encoder.restEncoder, 0) // => no need for an extra function call, just write 0 deletes
|
||||
return { missing: missingSV, update: encoder.toUint8Array() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and apply a document update.
|
||||
*
|
||||
* This function has the same effect as `applyUpdate` but accepts a decoder.
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {Doc} ydoc
|
||||
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} [structDecoder]
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = new UpdateDecoderV2(decoder)) =>
|
||||
ydoc.transact(transaction => {
|
||||
// force that transaction.local is set to non-local
|
||||
transaction.local = false
|
||||
let retry = false
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
// let start = performance.now()
|
||||
const ss = readBlockSet(structDecoder)
|
||||
const knownState = createIdSet()
|
||||
ss.clients.forEach((_, client) => {
|
||||
const storeStructs = store.clients.get(client)
|
||||
if (storeStructs) {
|
||||
const last = storeStructs[storeStructs.length - 1]
|
||||
knownState.add(client, 0, last.id.clock + last.length)
|
||||
// remove known items from ss
|
||||
store.skips.clients.get(client)?.getIds().forEach(idrange => {
|
||||
knownState.delete(client, idrange.clock, idrange.len)
|
||||
})
|
||||
}
|
||||
})
|
||||
// remove known items from ss
|
||||
ss.exclude(knownState)
|
||||
// console.log('time to read structs: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
// console.log('time to merge: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
const restStructs = integrateStructs(transaction, store, ss)
|
||||
const pending = store.pendingStructs
|
||||
if (pending) {
|
||||
// check if we can apply something
|
||||
for (const [client, clock] of pending.missing) {
|
||||
if (ss.clients.has(client) || clock < store.getClock(client)) {
|
||||
retry = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (restStructs) {
|
||||
// merge restStructs into store.pending
|
||||
for (const [client, clock] of restStructs.missing) {
|
||||
const mclock = pending.missing.get(client)
|
||||
if (mclock == null || mclock > clock) {
|
||||
pending.missing.set(client, clock)
|
||||
}
|
||||
}
|
||||
pending.update = mergeUpdatesV2([pending.update, restStructs.update])
|
||||
}
|
||||
} else {
|
||||
store.pendingStructs = restStructs
|
||||
}
|
||||
// console.log('time to integrate: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
const dsRest = readAndApplyDeleteSet(structDecoder, transaction, store)
|
||||
if (store.pendingDs) {
|
||||
// @todo we could make a lower-bound state-vector check as we do above
|
||||
const pendingDSUpdate = new UpdateDecoderV2(decoding.createDecoder(store.pendingDs))
|
||||
decoding.readVarUint(pendingDSUpdate.restDecoder) // read 0 structs, because we only encode deletes in pendingdsupdate
|
||||
const dsRest2 = readAndApplyDeleteSet(pendingDSUpdate, transaction, store)
|
||||
if (dsRest && dsRest2) {
|
||||
// case 1: ds1 != null && ds2 != null
|
||||
store.pendingDs = mergeUpdatesV2([dsRest, dsRest2])
|
||||
} else {
|
||||
// case 2: ds1 != null
|
||||
// case 3: ds2 != null
|
||||
// case 4: ds1 == null && ds2 == null
|
||||
store.pendingDs = dsRest || dsRest2
|
||||
}
|
||||
} else {
|
||||
// Either dsRest == null && pendingDs == null OR dsRest != null
|
||||
store.pendingDs = dsRest
|
||||
}
|
||||
// console.log('time to cleanup: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
|
||||
// console.log('time to resume delete readers: ', performance.now() - start) // @todo remove
|
||||
// start = performance.now()
|
||||
if (retry) {
|
||||
const update = /** @type {{update: Uint8Array}} */ (store.pendingStructs).update
|
||||
store.pendingStructs = null
|
||||
applyUpdateV2(transaction.doc, update)
|
||||
}
|
||||
}, transactionOrigin, false)
|
||||
|
||||
/**
|
||||
* Read and apply a document update.
|
||||
*
|
||||
* This function has the same effect as `applyUpdate` but accepts a decoder.
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {Doc} ydoc
|
||||
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readUpdate = (decoder, ydoc, transactionOrigin) => readUpdateV2(decoder, ydoc, transactionOrigin, new UpdateDecoderV1(decoder))
|
||||
|
||||
/**
|
||||
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
|
||||
*
|
||||
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
|
||||
*
|
||||
* @param {Doc} ydoc
|
||||
* @param {Uint8Array} update
|
||||
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const applyUpdateV2 = (ydoc, update, transactionOrigin, YDecoder = UpdateDecoderV2) => {
|
||||
const decoder = decoding.createDecoder(update)
|
||||
readUpdateV2(decoder, ydoc, transactionOrigin, new YDecoder(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
|
||||
*
|
||||
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
|
||||
*
|
||||
* @param {Doc} ydoc
|
||||
* @param {Uint8Array} update
|
||||
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const applyUpdate = (ydoc, update, transactionOrigin) => applyUpdateV2(ydoc, update, transactionOrigin, UpdateDecoderV1)
|
||||
|
||||
/**
|
||||
* Write all the document as a single update message. If you specify the state of the remote client (`targetStateVector`) it will
|
||||
* only write the operations that are missing.
|
||||
*
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
* @param {Doc} doc
|
||||
* @param {Map<number,number>} [targetStateVector] The state of the target that receives the update. Leave empty to write all known structs
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const writeStateAsUpdate = (encoder, doc, targetStateVector = new Map()) => {
|
||||
writeClientsStructs(encoder, doc.store, targetStateVector)
|
||||
writeIdSet(encoder, doc.store.ds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
|
||||
* only write the operations that are missing.
|
||||
*
|
||||
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} [encoder]
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector = new Uint8Array([0]), encoder = new UpdateEncoderV2()) => {
|
||||
const targetStateVector = decodeStateVector(encodedTargetStateVector)
|
||||
writeStateAsUpdate(encoder, doc, targetStateVector)
|
||||
const updates = [encoder.toUint8Array()]
|
||||
// also add the pending updates (if there are any)
|
||||
if (doc.store.pendingDs) {
|
||||
updates.push(doc.store.pendingDs)
|
||||
}
|
||||
if (doc.store.pendingStructs) {
|
||||
updates.push(diffUpdateV2(doc.store.pendingStructs.update, encodedTargetStateVector))
|
||||
}
|
||||
if (updates.length > 1) {
|
||||
if (encoder.constructor === UpdateEncoderV1) {
|
||||
return mergeUpdates(updates.map((update, i) => i === 0 ? update : convertUpdateFormatV2ToV1(update)))
|
||||
} else if (encoder.constructor === UpdateEncoderV2) {
|
||||
return mergeUpdatesV2(updates)
|
||||
}
|
||||
}
|
||||
return updates[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
|
||||
* only write the operations that are missing.
|
||||
*
|
||||
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new UpdateEncoderV1())
|
||||
|
||||
/**
|
||||
* Read state vector from Decoder and return as Map
|
||||
*
|
||||
* @param {IdSetDecoderV1 | IdSetDecoderV2} decoder
|
||||
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readStateVector = decoder => {
|
||||
const ss = new Map()
|
||||
const ssLength = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < ssLength; i++) {
|
||||
const client = decoding.readVarUint(decoder.restDecoder)
|
||||
const clock = decoding.readVarUint(decoder.restDecoder)
|
||||
ss.set(client, clock)
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* This function works similarly to `readUpdateV2`.
|
||||
*
|
||||
* @param {Array<Uint8Array<ArrayBuffer>>} updates
|
||||
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*/
|
||||
export const mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
|
||||
if (updates.length === 1) {
|
||||
return updates[0]
|
||||
} else if (updates.length === 0) {
|
||||
return encodeStateAsUpdateV2(new Doc(), new Uint8Array([0]), new YEncoder())
|
||||
}
|
||||
const updateDecoders = updates.map(update => new YDecoder(decoding.createDecoder(update)))
|
||||
const blocksets = updateDecoders.map(dec => readBlockSet(dec))
|
||||
|
||||
const mergedBlockset = blocksets[0]
|
||||
for (let i = 1; i < blocksets.length; i++) {
|
||||
mergedBlockset.insertInto(blocksets[i])
|
||||
}
|
||||
const updateEncoder = new YEncoder()
|
||||
writeBlockSet(updateEncoder, mergedBlockset)
|
||||
const dss = updateDecoders.map(decoder => readIdSet(decoder))
|
||||
const ds = mergeIdSets(dss)
|
||||
writeIdSet(updateEncoder, ds)
|
||||
return updateEncoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Uint8Array<ArrayBuffer>>} updates
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*/
|
||||
export const mergeUpdates = updates => mergeUpdatesV2(updates, UpdateDecoderV1, UpdateEncoderV1)
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @param {Uint8Array} update
|
||||
* @param {Uint8Array} sv
|
||||
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
|
||||
*/
|
||||
export const diffUpdateV2 = (update, sv, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
|
||||
const state = decodeStateVector(sv)
|
||||
const encoder = new YEncoder()
|
||||
const lazyStructWriter = new LazyStructWriter(encoder)
|
||||
const decoder = new YDecoder(decoding.createDecoder(update))
|
||||
const reader = new LazyStructReader(decoder, false)
|
||||
while (reader.curr) {
|
||||
const curr = reader.curr
|
||||
const currClient = curr.id.client
|
||||
const svClock = state.get(currClient) || 0
|
||||
if (reader.curr.constructor === Skip) {
|
||||
// the first written struct shouldn't be a skip
|
||||
reader.next()
|
||||
continue
|
||||
}
|
||||
if (curr.id.clock + curr.length > svClock) {
|
||||
writeStructToLazyStructWriter(lazyStructWriter, curr, math.max(svClock - curr.id.clock, 0), 0)
|
||||
reader.next()
|
||||
while (reader.curr && reader.curr.id.client === currClient) {
|
||||
writeStructToLazyStructWriter(lazyStructWriter, reader.curr, 0, 0)
|
||||
reader.next()
|
||||
}
|
||||
} else {
|
||||
// read until something new comes up
|
||||
while (reader.curr && reader.curr.id.client === currClient && reader.curr.id.clock + reader.curr.length <= svClock) {
|
||||
reader.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
finishLazyStructWriting(lazyStructWriter)
|
||||
// write ds
|
||||
const ds = readIdSet(decoder)
|
||||
writeIdSet(encoder, ds)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @todo remove this in favor of intersectupdate
|
||||
*
|
||||
* @param {Uint8Array<ArrayBuffer>} update
|
||||
* @param {Uint8Array<ArrayBuffer>} sv
|
||||
*/
|
||||
export const diffUpdate = (update, sv) => diffUpdateV2(update, sv, UpdateDecoderV1, UpdateEncoderV1)
|
||||
|
||||
/**
|
||||
* Read decodedState and return State as Map.
|
||||
*
|
||||
* @param {Uint8Array} decodedState
|
||||
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
// export const decodeStateVectorV2 = decodedState => readStateVector(new DSDecoderV2(decoding.createDecoder(decodedState)))
|
||||
|
||||
/**
|
||||
* Read decodedState and return State as Map.
|
||||
*
|
||||
* @param {Uint8Array} decodedState
|
||||
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const decodeStateVector = decodedState => readStateVector(new IdSetDecoderV1(decoding.createDecoder(decodedState)))
|
||||
|
||||
/**
|
||||
* @param {IdSetEncoderV1 | IdSetEncoderV2} encoder
|
||||
* @param {Map<number,number>} sv
|
||||
* @function
|
||||
*/
|
||||
export const writeStateVector = (encoder, sv) => {
|
||||
encoding.writeVarUint(encoder.restEncoder, sv.size)
|
||||
array.from(sv.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
|
||||
encoding.writeVarUint(encoder.restEncoder, client) // @todo use a special client decoder that is based on mapping
|
||||
encoding.writeVarUint(encoder.restEncoder, clock)
|
||||
})
|
||||
return encoder
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IdSetEncoderV1 | IdSetEncoderV2} encoder
|
||||
* @param {Doc} doc
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store))
|
||||
|
||||
/**
|
||||
* Encode State as Uint8Array.
|
||||
*
|
||||
* @param {Doc|Map<number,number>} doc
|
||||
* @param {IdSetEncoderV1 | IdSetEncoderV2} [encoder]
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateVectorV2 = (doc, encoder = new IdSetEncoderV2()) => {
|
||||
if (doc instanceof Map) {
|
||||
writeStateVector(encoder, doc)
|
||||
} else {
|
||||
writeDocumentStateVector(encoder, doc)
|
||||
}
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode State as Uint8Array.
|
||||
*
|
||||
* @param {Doc|Map<number,number>} doc
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateVector = doc => encodeStateVectorV2(doc, new IdSetEncoderV1())
|
||||
|
||||
/**
|
||||
* Return the creator clientID of the missing op or define missing items and return null.
|
||||
*
|
||||
* @param {Item} struct
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @return {null | number}
|
||||
*/
|
||||
const getMissing = (struct, transaction, store) => {
|
||||
if (struct.constructor !== Item) return null
|
||||
// we may not access these variables anymore after they have been written!
|
||||
const origin = struct.origin
|
||||
const rightOrigin = struct.rightOrigin
|
||||
const parent = struct.parent
|
||||
if (origin && (origin.clock >= store.getClock(origin.client) || store.skips.hasId(origin))) {
|
||||
return origin.client
|
||||
}
|
||||
if (rightOrigin && (rightOrigin.clock >= store.getClock(rightOrigin.client) || store.skips.hasId(rightOrigin))) {
|
||||
return rightOrigin.client
|
||||
}
|
||||
if (parent && parent.constructor === ID && (parent.clock >= store.getClock(parent.client) || store.skips.hasId(parent))) {
|
||||
return parent.client
|
||||
}
|
||||
// We have all missing ids, now find the items
|
||||
if (origin) {
|
||||
struct.left = getItemCleanEnd(transaction, store, origin)
|
||||
// copy left id to so that the original id can be gc'd
|
||||
struct.origin = struct.left.lastId
|
||||
}
|
||||
if (rightOrigin) {
|
||||
struct.right = getItemCleanStart(transaction, rightOrigin)
|
||||
struct.rightOrigin = struct.right.id
|
||||
}
|
||||
if ((struct.left && struct.left.constructor === GC) || (struct.right && struct.right.constructor === GC)) {
|
||||
struct.parent = null
|
||||
} else if (parent == null) {
|
||||
// only set parent if this shouldn't be garbage collected
|
||||
if (struct.left && struct.left.constructor === Item) {
|
||||
struct.parent = struct.left.parent
|
||||
struct.parentSub = struct.left.parentSub
|
||||
} else if (struct.right && struct.right.constructor === Item) {
|
||||
struct.parent = struct.right.parent
|
||||
struct.parentSub = struct.right.parentSub
|
||||
}
|
||||
} else if (parent.constructor === ID) {
|
||||
const parentItem = store.getItem(parent)
|
||||
if (parentItem.constructor === GC) {
|
||||
struct.parent = null
|
||||
} else {
|
||||
struct.parent = /** @type {ContentType} */ (parentItem.content).type
|
||||
}
|
||||
} else if (typeof parent === 'string') {
|
||||
struct.parent = transaction.doc.get(parent)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {import('./Doc.js').DocOpts} opts
|
||||
*/
|
||||
export const createDocFromUpdate = (update, opts = {}) => {
|
||||
const ydoc = new Doc(opts)
|
||||
applyUpdate(ydoc, update)
|
||||
return ydoc
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {import('./Doc.js').DocOpts} opts
|
||||
*/
|
||||
export const createDocFromUpdateV2 = (update, opts = {}) => {
|
||||
const ydoc = new Doc(opts)
|
||||
applyUpdateV2(ydoc, update)
|
||||
return ydoc
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Doc} ydoc
|
||||
* @param {import('./Doc.js').DocOpts} [opts]
|
||||
*/
|
||||
export const cloneDoc = (ydoc, opts) => {
|
||||
const clone = new Doc(opts)
|
||||
applyUpdate(clone, encodeStateAsUpdate(ydoc))
|
||||
return clone
|
||||
}
|
||||
1752
src/utils/ids.js
Normal file
1752
src/utils/ids.js
Normal file
File diff suppressed because it is too large
Load Diff
19
src/utils/isParentOf.js
Normal file
19
src/utils/isParentOf.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Check if `parent` is a parent of `child`.
|
||||
*
|
||||
* @param {YNode} parent
|
||||
* @param {Item|null} child
|
||||
* @return {Boolean} Whether `parent` is a parent of `child`.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const isParentOf = (parent, child) => {
|
||||
while (child !== null) {
|
||||
if (child.parent === parent) {
|
||||
return true
|
||||
}
|
||||
child = /** @type {YNode} */ (child.parent)._item
|
||||
}
|
||||
return false
|
||||
}
|
||||
17
src/utils/logging.js
Normal file
17
src/utils/logging.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Convenient helper to log type information.
|
||||
*
|
||||
* Do not use in productive systems as the output can be immense!
|
||||
*
|
||||
* @param {YNode<any>} type
|
||||
*/
|
||||
export const logNode = type => {
|
||||
const res = []
|
||||
let n = type._start
|
||||
while (n) {
|
||||
res.push(n)
|
||||
n = n.right
|
||||
}
|
||||
console.log('Children: ', res)
|
||||
console.log('Children content: ', res.filter(m => !m.deleted).map(m => m.content))
|
||||
}
|
||||
230
src/utils/meta.js
Normal file
230
src/utils/meta.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Meta API for describing Yjs documents
|
||||
*/
|
||||
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as s from 'lib0/schema'
|
||||
|
||||
import {
|
||||
$idSet,
|
||||
$idMapAny,
|
||||
createIdSetFromIdMap,
|
||||
createDeleteSetFromStructStore,
|
||||
createInsertSetFromStructStore,
|
||||
diffIdMap,
|
||||
mergeIdMaps,
|
||||
createIdSet,
|
||||
createIdMap,
|
||||
mergeIdSets,
|
||||
diffIdSet,
|
||||
createIdMapFromIdSet,
|
||||
writeIdSet,
|
||||
readIdSet,
|
||||
writeIdMap,
|
||||
readIdMap,
|
||||
intersectMaps,
|
||||
intersectSets,
|
||||
filterIdMap
|
||||
} from './ids.js'
|
||||
import { IdSetEncoderV2 } from './UpdateEncoder.js'
|
||||
import { IdSetDecoderV2 } from './UpdateDecoder.js'
|
||||
|
||||
/**
|
||||
* @param {IdSet} inserts
|
||||
* @param {IdSet} deletes
|
||||
* @return {ContentIds}
|
||||
*/
|
||||
export const createContentIds = (inserts = createIdSet(), deletes = createIdSet()) => ({ inserts, deletes })
|
||||
|
||||
/**
|
||||
* @param {ContentMap} contentMap
|
||||
*/
|
||||
export const createContentIdsFromContentMap = contentMap => createContentIds(
|
||||
createIdSetFromIdMap(contentMap.inserts),
|
||||
createIdSetFromIdMap(contentMap.deletes)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {import('./Doc.js').Doc} ydoc
|
||||
* @param {boolean} insertsContainDeletes - whether the returned `inserts` set includes content that
|
||||
* the doc has already deleted. `true` yields a full structural snapshot (every inserted id);
|
||||
* `false` yields only the doc's currently-visible content (`inserts − deletes`), which is what you
|
||||
* want when the snapshot defines what should render.
|
||||
*/
|
||||
export const createContentIdsFromDoc = (ydoc, insertsContainDeletes) => createContentIds(
|
||||
createInsertSetFromStructStore(ydoc.store, !insertsContainDeletes),
|
||||
createDeleteSetFromStructStore(ydoc.store)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {import('./Doc.js').Doc} ydocPrev
|
||||
* @param {import('./Doc.js').Doc} ydocNext
|
||||
*/
|
||||
export const createContentIdsFromDocDiff = (ydocPrev, ydocNext) =>
|
||||
excludeContentIds(createContentIdsFromDoc(ydocPrev, true), createContentIdsFromDoc(ydocNext, true))
|
||||
|
||||
/**
|
||||
* @param {ContentIds} content
|
||||
* @param {ContentIds} excludeContent
|
||||
*/
|
||||
export const excludeContentIds = (content, excludeContent) =>
|
||||
createContentIds(diffIdSet(content.inserts, excludeContent.inserts), diffIdSet(content.deletes, excludeContent.deletes))
|
||||
|
||||
/**
|
||||
* @param {ContentMap} content
|
||||
* @param {ContentIds | ContentMap} excludeContent
|
||||
*/
|
||||
export const excludeContentMap = (content, excludeContent) => createContentMap(
|
||||
diffIdMap(content.inserts, excludeContent.inserts),
|
||||
diffIdMap(content.deletes, excludeContent.deletes)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {Array<ContentMap>} contents
|
||||
*/
|
||||
export const mergeContentMaps = contents => createContentMap(
|
||||
mergeIdMaps(contents.map(c => c.inserts)),
|
||||
mergeIdMaps(contents.map(c => c.deletes))
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {Array<ContentIds>} contents
|
||||
*/
|
||||
export const mergeContentIds = contents => createContentIds(
|
||||
mergeIdSets(contents.map(c => c.inserts)),
|
||||
mergeIdSets(contents.map(c => c.deletes))
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {IdMap<any>} [inserts]
|
||||
* @param {IdMap<any>} [deletes]
|
||||
* @return {ContentMap}
|
||||
*/
|
||||
export const createContentMap = (inserts = createIdMap(), deletes = createIdMap()) => ({ inserts, deletes })
|
||||
|
||||
/**
|
||||
* @param {ContentIds} contentIds
|
||||
* @param {Array<ContentAttribute<any>>} insertAttrs
|
||||
* @param {Array<ContentAttribute<any>>} [deleteAttrs]
|
||||
*/
|
||||
export const createContentMapFromContentIds = (contentIds, insertAttrs, deleteAttrs = insertAttrs) => createContentMap(
|
||||
createIdMapFromIdSet(contentIds.inserts, insertAttrs),
|
||||
createIdMapFromIdSet(contentIds.deletes, deleteAttrs)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {import('./UpdateEncoder.js').IdSetEncoder} encoder
|
||||
* @param {ContentIds} contentIds
|
||||
*/
|
||||
export const writeContentIds = (encoder, contentIds) => {
|
||||
writeIdSet(encoder, contentIds.inserts)
|
||||
writeIdSet(encoder, contentIds.deletes)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentIds} contentIds
|
||||
*/
|
||||
export const encodeContentIds = contentIds => {
|
||||
const encoder = new IdSetEncoderV2()
|
||||
writeContentIds(encoder, contentIds)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo this encoding needs to be heavily optimized for production
|
||||
*
|
||||
* @param {import('./UpdateDecoder.js').IdSetDecoder} decoder
|
||||
* @return {ContentIds}
|
||||
*/
|
||||
export const readContentIds = decoder => createContentIds(
|
||||
readIdSet(decoder),
|
||||
readIdSet(decoder)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {Uint8Array<any>} buf
|
||||
*/
|
||||
export const decodeContentIds = buf => readContentIds(new IdSetDecoderV2(decoding.createDecoder(buf)))
|
||||
|
||||
/**
|
||||
* @todo this encoding needs to be heavily optimized for production
|
||||
*
|
||||
* @param {import('./UpdateEncoder.js').IdSetEncoder} encoder
|
||||
* @param {ContentMap} contentMap
|
||||
*/
|
||||
export const writeContentMap = (encoder, contentMap) => {
|
||||
writeIdMap(encoder, contentMap.inserts)
|
||||
writeIdMap(encoder, contentMap.deletes)
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo this encoding needs to be heavily optimized for production
|
||||
*
|
||||
* @param {import('./UpdateDecoder.js').IdSetDecoder} decoder
|
||||
* @return {ContentMap} contentMap
|
||||
*/
|
||||
export const readContentMap = (decoder) => createContentMap(
|
||||
readIdMap(decoder),
|
||||
readIdMap(decoder)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {ContentMap} contentMap
|
||||
*/
|
||||
export const encodeContentMap = contentMap => {
|
||||
const encoder = new IdSetEncoderV2()
|
||||
writeContentMap(encoder, contentMap)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentMap} mapA
|
||||
* @param {ContentMap|ContentIds} mapB
|
||||
*/
|
||||
export const intersectContentMap = (mapA, mapB) => createContentMap(
|
||||
intersectMaps(mapA.inserts, mapB.inserts),
|
||||
intersectMaps(mapA.deletes, mapB.deletes)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {ContentIds} setA
|
||||
* @param {ContentIds|ContentMap} setB
|
||||
*/
|
||||
export const intersectContentIds = (setA, setB) => createContentIds(
|
||||
intersectSets(setA.inserts, setB.inserts),
|
||||
intersectSets(setA.deletes, setB.deletes)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {Uint8Array<any>} buf
|
||||
*/
|
||||
export const decodeContentMap = buf => readContentMap(new IdSetDecoderV2(decoding.createDecoder(buf)))
|
||||
|
||||
/**
|
||||
* @todo filter by array of content instead
|
||||
* @param {ContentMap} contentMap
|
||||
* @param {(c:Array<ContentAttribute<any>>)=>boolean} insertPredicate
|
||||
* @param {(c:Array<ContentAttribute<any>>)=>boolean} deletePredicate
|
||||
*/
|
||||
export const filterContentMap = (contentMap, insertPredicate, deletePredicate) => createContentMap(filterIdMap(contentMap.inserts, insertPredicate), filterIdMap(contentMap.deletes, deletePredicate))
|
||||
|
||||
/**
|
||||
* Schema of {@link ContentIds} - the shape produced by {@link createContentIds}.
|
||||
*
|
||||
* Structural: a `ContentIds` is a plain object literal, so there is no prototype to tag. The
|
||||
* *nominal* {@link $idSet} checks on the two fields are what discriminate it from a
|
||||
* {@link ContentMap} in both directions.
|
||||
*
|
||||
* @type {s.Schema<ContentIds>}
|
||||
*/
|
||||
export const $contentIds = s.$object({ inserts: $idSet, deletes: $idSet })
|
||||
|
||||
/**
|
||||
* Schema of {@link ContentMap} - the shape produced by {@link createContentMap}.
|
||||
*
|
||||
* See {@link $contentIds}. Use {@link $idMap} on the individual fields to additionally constrain
|
||||
* the mapped-value type; `ContentMap` itself hardcodes `IdMap<any>`.
|
||||
*
|
||||
* @type {s.Schema<ContentMap>}
|
||||
*/
|
||||
export const $contentMap = s.$object({ inserts: $idMapAny, deletes: $idMapAny })
|
||||
314
src/utils/position-helpers.js
Normal file
314
src/utils/position-helpers.js
Normal file
@@ -0,0 +1,314 @@
|
||||
import * as dpos from 'lib0/delta/position'
|
||||
import * as map from 'lib0/map'
|
||||
|
||||
import { createRelativePosition, createAbsolutePositionFromRelativePosition } from './RelativePosition.js'
|
||||
import { rendererContentLength } from './renderer-helpers.js'
|
||||
import { getPathTo } from './YEvent.js'
|
||||
import { isParentOf } from './isParentOf.js'
|
||||
import { createID, findRootTypeKey } from './ID.js'
|
||||
import { $doc } from './schemas.js'
|
||||
import { Item, ContentType, followRedone } from '../structs/Item.js'
|
||||
|
||||
/**
|
||||
* Transform lib0 delta positions ({@link DeltaPosition} — path-based, ephemeral, relative to a
|
||||
* rendered delta tree) to {@link import('./RelativePosition.js').RelativePosition}s (item-ID-based,
|
||||
* converging) and back. Use this to persist or sync cursors/marks (e.g. the result of
|
||||
* `position.marksToPositions(node.delta)`) robustly through Yjs.
|
||||
*
|
||||
* Both directions operate on a set of positions and return results 1:1 with the input (`null` for
|
||||
* positions that can't be resolved). Extra fields of a `MarkPos` (`id`, `attrs`) are ignored — zip
|
||||
* them back yourself: `marksToPositions(d).map((m, i) => ({ id: m.id, rpos: rposs[i] }))`.
|
||||
*
|
||||
* A delta position is only meaningful against the delta produced by a specific renderer. Both
|
||||
* transforms default to the root node's renderer (`root.useRenderer(..)`) — the same default that
|
||||
* `node.toDelta()` / `node.delta` use — so positions taken from those deltas resolve correctly
|
||||
* without options. When the delta was rendered differently (e.g. a doc-rooted `diffDocsToDelta`),
|
||||
* pass that renderer explicitly.
|
||||
*
|
||||
* @module position-helpers
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('lib0/delta/position').Pos} DeltaPosition
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./Renderer.js').AbstractRenderer} AbstractRenderer
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {YNode<any>|Doc} root
|
||||
* @param {{ renderer?: AbstractRenderer|null }} opts
|
||||
* @return {AbstractRenderer|null}
|
||||
*/
|
||||
const resolveRenderer = (root, opts) => opts.renderer !== undefined ? opts.renderer : ($doc.check(root) ? null : root._renderer)
|
||||
|
||||
/**
|
||||
* The child node at a rendered slot / map entry: node children are always ContentType items.
|
||||
* Scalars (and subdocs) yield `null` — a delta position cannot descend into them.
|
||||
*
|
||||
* @param {Item} item
|
||||
* @return {YNode<any>|null}
|
||||
*/
|
||||
const contentTypeChild = item => item.content.constructor === ContentType ? /** @type {ContentType} */ (item.content).type : null
|
||||
|
||||
/**
|
||||
* The current map entry for `key`, unless it is absent or deleted without being rendered
|
||||
* (mirrors the renderer-aware map lookup of `applyDelta`).
|
||||
*
|
||||
* @param {YNode<any>} node
|
||||
* @param {string} key
|
||||
* @param {AbstractRenderer|null} renderer
|
||||
* @return {Item|null}
|
||||
*/
|
||||
const mapItemGet = (node, key, renderer) => {
|
||||
const it = node._map.get(key)
|
||||
return it === undefined || (it.deleted && rendererContentLength(renderer, it) === 0) ? null : it
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform delta positions (paths relative to `root`) to relative positions.
|
||||
*
|
||||
* Positions are resolved as a batch: one linked-list walk per visited node serves all positions
|
||||
* inside it, so many cursors in the same text cost a single traversal.
|
||||
*
|
||||
* When `root` is a Doc, paths must start with a root-type name (matching the doc-rooted deltas of
|
||||
* `diffDocsToDelta`). A terminal string step (attribute leaf, e.g. `['key']`) anchors the map
|
||||
* entry's item — such a relative position round-trips through this module, but its index is
|
||||
* meaningless to `createAbsolutePositionFromRelativePosition`. Unresolvable positions map to
|
||||
* `null`: the empty path `[]` (a node reference doesn't round-trip), descent into scalars or
|
||||
* subdocs, missing attributes, and out-of-range non-terminal indexes.
|
||||
*
|
||||
* @param {YNode<any>|Doc} root
|
||||
* @param {Array<DeltaPosition>} positions
|
||||
* @param {{ renderer?: AbstractRenderer|null }} [opts]
|
||||
* @return {Array<import('./RelativePosition.js').RelativePosition|null>}
|
||||
*/
|
||||
export const createRelativePositionsFromDeltaPositions = (root, positions, opts = {}) => {
|
||||
const renderer = resolveRenderer(root, opts)
|
||||
/**
|
||||
* @type {Array<import('./RelativePosition.js').RelativePosition|null>}
|
||||
*/
|
||||
const results = new Array(positions.length).fill(null)
|
||||
/**
|
||||
* @typedef {{ posIdx: number, pos: DeltaPosition, depth: number }} Task
|
||||
*/
|
||||
/**
|
||||
* @type {Map<YNode<any>, Array<Task>>}
|
||||
*/
|
||||
let layer = new Map()
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const pos = positions[i]
|
||||
if (pos == null || pos.path.length === 0) continue
|
||||
if ($doc.check(root)) {
|
||||
const step0 = pos.path[0]
|
||||
// doc-rooted paths start with a root-type name; root types are not items, so a terminal
|
||||
// string at doc level has nothing to anchor
|
||||
if (typeof step0 !== 'string' || pos.path.length === 1) continue
|
||||
map.setIfUndefined(layer, root.get(step0), () => /** @type {Array<Task>} */ ([])).push({ posIdx: i, pos, depth: 1 })
|
||||
} else {
|
||||
map.setIfUndefined(layer, root, () => /** @type {Array<Task>} */ ([])).push({ posIdx: i, pos, depth: 0 })
|
||||
}
|
||||
}
|
||||
while (layer.size > 0) {
|
||||
/**
|
||||
* @type {Map<YNode<any>, Array<Task>>}
|
||||
*/
|
||||
const nextLayer = new Map()
|
||||
layer.forEach((tasks, node) => {
|
||||
/**
|
||||
* @type {Array<{ eff: number, isLast: boolean, task: Task }>}
|
||||
*/
|
||||
const walkTargets = []
|
||||
for (const task of tasks) {
|
||||
const { posIdx, pos, depth } = task
|
||||
const step = pos.path[depth]
|
||||
const isLast = depth === pos.path.length - 1
|
||||
if (typeof step === 'string') {
|
||||
const it = mapItemGet(node, step, renderer)
|
||||
if (it === null) continue
|
||||
if (isLast) {
|
||||
// attribute leaf: anchor the item holding the attribute value
|
||||
results[posIdx] = createRelativePosition(node, it.lastId, pos.assoc === -1 ? -1 : 0)
|
||||
} else {
|
||||
const child = contentTypeChild(it)
|
||||
if (child !== null) {
|
||||
map.setIfUndefined(nextLayer, child, () => /** @type {Array<Task>} */ ([])).push({ posIdx, pos, depth: depth + 1 })
|
||||
}
|
||||
}
|
||||
} else if (Number.isInteger(step) && step >= 0) {
|
||||
if (isLast && pos.assoc === -1 && step === 0) {
|
||||
results[posIdx] = createRelativePosition(node, null, -1)
|
||||
} else {
|
||||
// a left-associated cursor gap binds to the preceding character (cf. createRelativePositionFromTypeIndex)
|
||||
walkTargets.push({ eff: isLast && pos.assoc === -1 ? step - 1 : step, isLast, task })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (walkTargets.length > 0) {
|
||||
// one walk over the item list resolves all targets of this node
|
||||
walkTargets.sort((a, b) => a.eff - b.eff)
|
||||
let item = node._start
|
||||
let itemLen = item === null ? 0 : rendererContentLength(renderer, item)
|
||||
let cum = 0
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let last = null
|
||||
for (const { eff, isLast, task } of walkTargets) {
|
||||
while (item !== null && cum + itemLen <= eff) {
|
||||
cum += itemLen
|
||||
last = item
|
||||
item = item.right
|
||||
itemLen = item === null ? 0 : rendererContentLength(renderer, item)
|
||||
}
|
||||
if (item !== null) {
|
||||
const offset = eff - cum
|
||||
if (isLast) {
|
||||
results[task.posIdx] = createRelativePosition(node, createID(item.id.client, item.id.clock + offset), task.pos.assoc === -1 ? -1 : 0)
|
||||
} else {
|
||||
const child = contentTypeChild(item)
|
||||
if (child !== null) {
|
||||
map.setIfUndefined(nextLayer, child, () => /** @type {Array<Task>} */ ([])).push({ posIdx: task.posIdx, pos: task.pos, depth: task.depth + 1 })
|
||||
}
|
||||
}
|
||||
} else if (isLast) {
|
||||
// walked past the end — same semantics as createRelativePositionFromTypeIndex
|
||||
const yassoc = task.pos.assoc === -1 ? -1 : 0
|
||||
results[task.posIdx] = createRelativePosition(node, yassoc < 0 && last !== null ? last.lastId : null, yassoc)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
layer = nextLayer
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {YNode<any>} node
|
||||
* @param {AbstractRenderer|null} renderer
|
||||
* @return {number}
|
||||
*/
|
||||
const renderedLength = (node, renderer) => {
|
||||
let len = 0
|
||||
for (let item = node._start; item !== null; item = item.right) {
|
||||
len += rendererContentLength(renderer, item)
|
||||
}
|
||||
return len
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform relative positions to delta positions (paths relative to `root`).
|
||||
*
|
||||
* Resolution is batched: the path from `root` to a node is computed once per distinct node, however
|
||||
* many positions live inside it. Positions outside of `root`'s subtree (or from another doc) map to
|
||||
* `null`, as do attribute-leaf anchors whose attribute no longer exists.
|
||||
*
|
||||
* `followUndoneDeletions` (default `true`) matches `createAbsolutePositionFromRelativePosition` —
|
||||
* set it to `false` for consistent results across clients (https://github.com/yjs/yjs/issues/638).
|
||||
* The path from `root` down to the position's node never follows undone deletions.
|
||||
*
|
||||
* @param {YNode<any>|Doc} root
|
||||
* @param {Array<import('./RelativePosition.js').RelativePosition|null>} rposs - `null` entries (e.g. from the forward transform) map to `null`
|
||||
* @param {{ renderer?: AbstractRenderer|null, followUndoneDeletions?: boolean }} [opts]
|
||||
* @return {Array<DeltaPosition|null>}
|
||||
*/
|
||||
export const createDeltaPositionsFromRelativePositions = (root, rposs, opts = {}) => {
|
||||
const renderer = resolveRenderer(root, opts)
|
||||
const followUndoneDeletions = opts.followUndoneDeletions ?? true
|
||||
const isDocRoot = $doc.check(root)
|
||||
const doc = isDocRoot ? /** @type {Doc} */ (root) : /** @type {YNode<any>} */ (root).doc
|
||||
/**
|
||||
* @type {Array<DeltaPosition|null>}
|
||||
*/
|
||||
const results = new Array(rposs.length).fill(null)
|
||||
if (doc === null) return results
|
||||
const store = doc.store
|
||||
/**
|
||||
* @param {YNode<any>} node
|
||||
* @return {Array<string|number>|null}
|
||||
*/
|
||||
const pathFromRoot = node => {
|
||||
if (isDocRoot) {
|
||||
if (node.doc !== doc) return null
|
||||
let top = node
|
||||
while (top._item !== null) {
|
||||
top = /** @type {YNode<any>} */ (top._item.parent)
|
||||
}
|
||||
return [findRootTypeKey(top), ...getPathTo(top, node, renderer)]
|
||||
}
|
||||
if (node !== root && (node._item === null || !isParentOf(/** @type {YNode<any>} */ (root), node._item))) return null
|
||||
return getPathTo(/** @type {YNode<any>} */ (root), node, renderer)
|
||||
}
|
||||
/**
|
||||
* @type {Map<YNode<any>, Array<string|number>|null>}
|
||||
*/
|
||||
const pathCache = new Map()
|
||||
/**
|
||||
* @type {Map<YNode<any>, number>}
|
||||
*/
|
||||
const renderedLenCache = new Map()
|
||||
for (let i = 0; i < rposs.length; i++) {
|
||||
const rpos = rposs[i]
|
||||
if (rpos == null) continue
|
||||
/**
|
||||
* @type {YNode<any>}
|
||||
*/
|
||||
let node
|
||||
/**
|
||||
* @type {string|number}
|
||||
*/
|
||||
let terminal
|
||||
if (rpos.item !== null) {
|
||||
const rightID = rpos.item
|
||||
if (store.getClock(rightID.client) <= rightID.clock) continue
|
||||
const { item } = followUndoneDeletions ? followRedone(store, rightID) : { item: store.getItem(rightID) }
|
||||
if (!(item instanceof Item)) continue
|
||||
if (item.parentSub !== null) {
|
||||
// attribute leaf (created by createRelativePositionsFromDeltaPositions)
|
||||
node = /** @type {YNode<any>} */ (item.parent)
|
||||
if (mapItemGet(node, item.parentSub, renderer) === null) continue
|
||||
terminal = item.parentSub
|
||||
} else {
|
||||
const apos = createAbsolutePositionFromRelativePosition(rpos, doc, followUndoneDeletions, renderer)
|
||||
if (apos === null) continue
|
||||
node = apos.type
|
||||
terminal = apos.index
|
||||
}
|
||||
} else {
|
||||
const apos = createAbsolutePositionFromRelativePosition(rpos, doc, followUndoneDeletions, renderer)
|
||||
if (apos === null) continue
|
||||
node = apos.type
|
||||
// the end-of-type index of createAbsolutePositionFromRelativePosition is the raw length —
|
||||
// a delta position must index the rendered tree
|
||||
terminal = renderer !== null && rpos.assoc >= 0
|
||||
? map.setIfUndefined(renderedLenCache, node, () => renderedLength(apos.type, renderer))
|
||||
: apos.index
|
||||
}
|
||||
const prefix = map.setIfUndefined(pathCache, node, () => pathFromRoot(/** @type {YNode<any>} */ (node)))
|
||||
if (prefix === null) continue
|
||||
results[i] = dpos.create(prefix.concat([terminal]), rpos.assoc < 0 ? -1 : 1)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single delta position to a relative position - see {@link createRelativePositionsFromDeltaPositions}.
|
||||
*
|
||||
* @param {YNode<any>|Doc} root
|
||||
* @param {DeltaPosition} pos
|
||||
* @param {{ renderer?: AbstractRenderer|null }} [opts]
|
||||
* @return {import('./RelativePosition.js').RelativePosition|null}
|
||||
*/
|
||||
export const createRelativePositionFromDeltaPosition = (root, pos, opts) => createRelativePositionsFromDeltaPositions(root, [pos], opts)[0]
|
||||
|
||||
/**
|
||||
* Transform a single relative position to a delta position - see {@link createDeltaPositionsFromRelativePositions}.
|
||||
*
|
||||
* @param {YNode<any>|Doc} root
|
||||
* @param {import('./RelativePosition.js').RelativePosition|null} rpos
|
||||
* @param {{ renderer?: AbstractRenderer|null, followUndoneDeletions?: boolean }} [opts]
|
||||
* @return {DeltaPosition|null}
|
||||
*/
|
||||
export const createDeltaPositionFromRelativePosition = (root, rpos, opts) => createDeltaPositionsFromRelativePositions(root, [rpos], opts)[0]
|
||||
123
src/utils/renderer-helpers.js
Normal file
123
src/utils/renderer-helpers.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as s from 'lib0/schema'
|
||||
import * as error from 'lib0/error'
|
||||
import { ObservableV2 } from 'lib0/observable'
|
||||
|
||||
import { createIdSet } from './ids.js'
|
||||
|
||||
export const attributionJsonSchema = s.$object({
|
||||
insert: s.$array(s.$string).optional,
|
||||
insertAt: s.$number.optional,
|
||||
delete: s.$array(s.$string).optional,
|
||||
deleteAt: s.$number.optional,
|
||||
format: s.$record(s.$string, s.$array(s.$string)).optional,
|
||||
formatAt: s.$number.optional
|
||||
})
|
||||
|
||||
/**
|
||||
* @todo rename this to `insertBy`, `insertAt`, ..
|
||||
*
|
||||
* @typedef {s.Unwrap<typeof attributionJsonSchema>} Attribution
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*/
|
||||
export class AttributedContent {
|
||||
/**
|
||||
* @param {AbstractContent} content
|
||||
* @param {number} clock
|
||||
* @param {boolean} deleted
|
||||
* @param {Array<ContentAttribute<T>> | null} attrs
|
||||
* @param {0|1|2|3} renderBehavior
|
||||
*/
|
||||
constructor (content, clock, deleted, attrs, renderBehavior) {
|
||||
this.content = content
|
||||
this.clock = clock
|
||||
this.deleted = deleted
|
||||
this.attrs = attrs
|
||||
this.render = renderBehavior === 0 ? false : (renderBehavior === 1 ? (!deleted || attrs != null) : true)
|
||||
/**
|
||||
* Fresh content that was deleted in the same transaction (mode `3`): even in a
|
||||
* `retainDeletes` render it must produce an *insert* — the consuming state (e.g. the
|
||||
* maintained `delta` cache) has never seen this content, so there is nothing to retain.
|
||||
*/
|
||||
this.fresh = renderBehavior === 3
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for renderers. A renderer renders Content (with Attributions) to a delta.
|
||||
*
|
||||
* Should fire an event when the attributions changed _after_ the original change happens. This
|
||||
* Event will be used to update the attribution on the current content.
|
||||
*
|
||||
* Only items claimed via {@link AbstractRenderer#hasItem} reach the renderer — everything else is
|
||||
* rendered by the generic fast path (as-is, without attributions, deleted content invisible).
|
||||
*
|
||||
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||
*/
|
||||
export class AbstractRenderer extends ObservableV2 {
|
||||
constructor () {
|
||||
super()
|
||||
/**
|
||||
* Ids of the content this renderer may render non-normally (attributed, restored, hidden, …).
|
||||
* May over-approximate — {@link AbstractRenderer#readContent} remains authoritative for what
|
||||
* is actually rendered. Content outside this set is rendered by the generic fast path without
|
||||
* consulting the renderer.
|
||||
*
|
||||
* @type {IdSet}
|
||||
*/
|
||||
this.attributed = createIdSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `item` (any part of its id range) must be rendered by this renderer. Items for which
|
||||
* this returns `false` are rendered by the generic fast path.
|
||||
*
|
||||
* @param {Item} item
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasItem (item) {
|
||||
return this.attributed.intersects(item.id.client, item.id.clock, item.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<AttributedContent<any>>} _contents - where to write the result
|
||||
* @param {number} _client
|
||||
* @param {number} _clock
|
||||
* @param {boolean} _deleted
|
||||
* @param {AbstractContent} _content
|
||||
* @param {0|1|2|3} _shouldRender - 0: if undeleted or attributed, render as a retain operation. 1: render only if undeleted or attributed. 2: render as insert operation (if unattributed and deleted, render as delete). 3: fresh content deleted in the same transaction, marked {@link AttributedContent#fresh} — render as insert where attributed (it must insert even in a `retainDeletes` render: the consuming state has never seen it), as *nothing* where unattributed (invisible; a delete op would misapply).
|
||||
*/
|
||||
readContent (_contents, _client, _clock, _deleted, _content, _shouldRender) {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the length of the attributed content. This is used by iterators that walk through the
|
||||
* content.
|
||||
*
|
||||
* If the content is not countable, it should return 0.
|
||||
*
|
||||
* @param {Item} _item
|
||||
* @return {number}
|
||||
*/
|
||||
contentLength (_item) {
|
||||
error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
export const $renderer = AbstractRenderer.prototype.$type = s.$type('y:renderer', AbstractRenderer)
|
||||
|
||||
/**
|
||||
* Rendered length of `item` under `renderer`: the generic rule — alive countable content renders
|
||||
* at full length, everything else at length `0` — unless the renderer claims the item.
|
||||
*
|
||||
* @param {AbstractRenderer?} renderer
|
||||
* @param {Item} item
|
||||
* @return {number}
|
||||
*/
|
||||
export const rendererContentLength = (renderer, item) =>
|
||||
renderer !== null && renderer.hasItem(item)
|
||||
? renderer.contentLength(item)
|
||||
: ((item.deleted || !item.content.isCountable()) ? 0 : item.length)
|
||||
3
src/utils/schemas.js
Normal file
3
src/utils/schemas.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as s from 'lib0/schema'
|
||||
|
||||
export const $doc = /** @type {s.Schema<Doc>} */ (s.$type('y:doc', null))
|
||||
413
src/utils/transaction-helpers.js
Normal file
413
src/utils/transaction-helpers.js
Normal file
@@ -0,0 +1,413 @@
|
||||
import * as math from 'lib0/math'
|
||||
import * as error from 'lib0/error'
|
||||
import * as map from 'lib0/map'
|
||||
import * as set from 'lib0/set'
|
||||
|
||||
import { createID } from './ID.js'
|
||||
|
||||
/**
|
||||
* These modules don't require any imports.
|
||||
* These helpers are used by items to integrate themselves
|
||||
*/
|
||||
|
||||
/**
|
||||
* Perform a binary search on a sorted array
|
||||
* @param {Array<Item|GC|Skip>} structs
|
||||
* @param {number} clock
|
||||
* @return {number}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const findIndexSS = (structs, clock) => {
|
||||
let left = 0
|
||||
let right = structs.length - 1
|
||||
let mid = structs[right]
|
||||
let midclock = mid.id.clock
|
||||
if (midclock === clock) {
|
||||
return right
|
||||
}
|
||||
// @todo does it even make sense to pivot the search?
|
||||
// If a good split misses, it might actually increase the time to find the correct item.
|
||||
// Currently, the only advantage is that search with pivoting might find the item on the first try.
|
||||
let midindex = math.floor((clock / (midclock + mid.length - 1)) * right) // pivoting the search
|
||||
while (left <= right) {
|
||||
mid = structs[midindex]
|
||||
midclock = mid.id.clock
|
||||
if (midclock <= clock) {
|
||||
if (clock < midclock + mid.length) {
|
||||
return midindex
|
||||
}
|
||||
left = midindex + 1
|
||||
} else {
|
||||
right = midindex - 1
|
||||
}
|
||||
midindex = math.floor((left + right) / 2)
|
||||
}
|
||||
// Always check state before looking for a struct in StructStore
|
||||
// Therefore the case of not finding a struct is unexpected
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction?} transaction
|
||||
* @param {Array<Item|GC|Skip>} structs
|
||||
* @param {number} clock
|
||||
*/
|
||||
export const findIndexCleanStart = (transaction, structs, clock) => {
|
||||
const index = findIndexSS(structs, clock)
|
||||
const struct = structs[index]
|
||||
if (struct.id.clock < clock) {
|
||||
structs.splice(index + 1, 0, splitStruct(transaction, struct, clock - struct.id.clock))
|
||||
return index + 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {ID} id
|
||||
* @return {Item}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const getItemCleanStart = (transaction, id) => {
|
||||
const structs = /** @type {Array<Item>} */ (transaction.doc.store.clients.get(id.client))
|
||||
return structs[findIndexCleanStart(transaction, structs, id.clock)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {ID} id
|
||||
* @return {Item}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const getItemCleanEnd = (transaction, store, id) => {
|
||||
const structs = store.clients.get(id.client) || []
|
||||
const index = findIndexSS(structs, id.clock)
|
||||
const struct = structs[index]
|
||||
if (id.clock !== struct.id.clock + struct.length - 1 && struct.isItem) {
|
||||
structs.splice(index + 1, 0, /** @type {Item} */ (struct).split(transaction, id.clock - struct.id.clock + 1))
|
||||
}
|
||||
return /** @type {Item} */ (struct)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `item` with `newitem` in store
|
||||
* @param {Transaction} tr
|
||||
* @param {GC|Item} struct
|
||||
* @param {GC|Item} newStruct
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const replaceStruct = (tr, struct, newStruct) => {
|
||||
const structs = /** @type {Array<GC|Item>} */ (tr.doc.store.clients.get(struct.id.client))
|
||||
structs[findIndexSS(structs, struct.id.clock)] = newStruct
|
||||
tr._mergeStructs.push(newStruct)
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over a range of structs
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Array<Item|GC>} structs
|
||||
* @param {number} clockStart Inclusive start
|
||||
* @param {number} len
|
||||
* @param {function(GC|Item):void} f
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const iterateStructs = (transaction, structs, clockStart, len, f) => {
|
||||
if (len === 0) {
|
||||
return
|
||||
}
|
||||
const clockEnd = clockStart + len
|
||||
let index = findIndexCleanStart(transaction, structs, clockStart)
|
||||
let struct
|
||||
do {
|
||||
struct = structs[index++]
|
||||
if (clockEnd < struct.id.clock + struct.length) {
|
||||
findIndexCleanStart(transaction, structs, clockEnd)
|
||||
}
|
||||
f(struct)
|
||||
} while (index < structs.length && structs[index].id.clock < clockEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over a range of structs without splitting the structs. You will get content that you did
|
||||
* not expect.
|
||||
*
|
||||
* @param {Array<Item|GC>} structs
|
||||
* @param {number} clockStart Inclusive start
|
||||
* @param {number} len
|
||||
* @param {(struct: GC|Item|Skip, offset:number, len:number)=>void} f
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const iterateStructsWithoutSplits = (structs, clockStart, len, f) => {
|
||||
if (len === 0) {
|
||||
return
|
||||
}
|
||||
const clockEnd = clockStart + len
|
||||
for (
|
||||
let index = findIndexSS(structs, clockStart), struct = structs[index], clock = struct.id.clock;
|
||||
clock < clockEnd;
|
||||
struct = structs[++index], clock = struct.id.clock
|
||||
) {
|
||||
const offset = clock < clockStart ? clockStart - clock : 0
|
||||
f(struct, offset, math.min(clock + struct.length, clockEnd) - clock)
|
||||
if (index + 1 === structs.length) break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* More generalized version of splitItem. Split leftStruct into two structs
|
||||
* @param {Transaction?} transaction
|
||||
* @param {Item|GC|Skip} leftStruct
|
||||
* @param {number} diff
|
||||
* @return {GC|Item|Skip}
|
||||
*
|
||||
* @function
|
||||
* @private
|
||||
*/
|
||||
export const splitStruct = (transaction, leftStruct, diff) => {
|
||||
if (leftStruct.isItem) {
|
||||
return /** @type {Item} */ (leftStruct).split(transaction, diff)
|
||||
} else {
|
||||
const rightItem = leftStruct.splice(diff)
|
||||
transaction?._mergeStructs.push(rightItem)
|
||||
return rightItem
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const nextID = transaction => {
|
||||
const y = transaction.doc
|
||||
return createID(y.clientID, y.store.getClock(y.clientID))
|
||||
}
|
||||
|
||||
/**
|
||||
* If `type.parent` was added in current transaction, `type` technically
|
||||
* did not change, it was just added and we should not fire events for `type`.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {YNode} type
|
||||
* @param {string|null} parentSub
|
||||
*/
|
||||
export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
|
||||
const item = type._item
|
||||
if (item === null || !transaction.insertSet.hasId(item.id)) {
|
||||
map.setIfUndefined(transaction.changed, type, set.create).add(parentSub)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<GC | Item | Skip>} structs
|
||||
* @param {number} pos
|
||||
* @return {number} # of merged structs
|
||||
*/
|
||||
export const tryToMergeWithLefts = (structs, pos) => {
|
||||
let right = structs[pos]
|
||||
let left = structs[pos - 1]
|
||||
let i = pos
|
||||
for (; i > 0; right = left, left = structs[--i - 1]) {
|
||||
if (left.deleted === right.deleted && left.constructor === right.constructor) {
|
||||
if (left.mergeWith(/** @type {any} */ (right))) {
|
||||
if (right.isItem && /** @type {Item} */ (right).parentSub !== null && /** @type {YNode} */ (/** @type {Item} */ (right).parent)._map.get(/** @type {Item} */ (right).parentSub) === right) {
|
||||
/** @type {YNode} */ (right.parent)._map.set(right.parentSub, /** @type {Item} */ (left))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
const merged = pos - i
|
||||
if (merged) {
|
||||
// remove all merged structs from the array
|
||||
structs.splice(pos + 1 - merged, merged)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} tr
|
||||
* @param {IdSet} ds
|
||||
* @param {function(Item):boolean} gcFilter
|
||||
*/
|
||||
export const tryGcDeleteSet = (tr, ds, gcFilter) => {
|
||||
for (const [client, _deleteItems] of ds.clients.entries()) {
|
||||
const deleteItems = _deleteItems.getIds()
|
||||
const structs = /** @type {Array<Item>} */ (tr.doc.store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
const endDeleteItemClock = deleteItem.clock + deleteItem.len
|
||||
for (
|
||||
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
|
||||
si < structs.length && struct.id.clock < endDeleteItemClock;
|
||||
struct = structs[++si]
|
||||
) {
|
||||
const struct = structs[si]
|
||||
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||
break
|
||||
}
|
||||
if (struct.isItem && struct.deleted && !(struct).keep && gcFilter(/** @type {Item} */ (struct))) {
|
||||
/** @type {Item} */ (struct).gc(tr, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IdSet} ds
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
export const tryMerge = (ds, store) => {
|
||||
// try to merge deleted / gc'd items
|
||||
// merge from right to left for better efficiency and so we don't miss any merge targets
|
||||
ds.clients.forEach((_deleteItems, client) => {
|
||||
const deleteItems = _deleteItems.getIds()
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
// start with merging the item next to the last deleted item
|
||||
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
|
||||
for (
|
||||
let si = mostRightIndexToCheck, struct = structs[si];
|
||||
si > 0 && struct.id.clock >= deleteItem.clock;
|
||||
struct = structs[si]
|
||||
) {
|
||||
si -= 1 + tryToMergeWithLefts(structs, si)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} tr
|
||||
* @param {IdSet} idset
|
||||
* @param {function(Item):boolean} gcFilter
|
||||
*/
|
||||
export const tryGc = (tr, idset, gcFilter) => {
|
||||
tryGcDeleteSet(tr, idset, gcFilter)
|
||||
tryMerge(idset, tr.doc.store)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item | null} item
|
||||
*/
|
||||
export const cleanupContextlessFormattingGap = (transaction, item) => {
|
||||
if (!transaction.doc.cleanupFormatting) return 0
|
||||
// iterate until item.right is null or content
|
||||
while (item && item.right && (item.right.deleted || !item.right.countable)) {
|
||||
item = item.right
|
||||
}
|
||||
const attrs = new Set()
|
||||
// iterate back until a content item is found
|
||||
while (item && (item.deleted || !item.countable)) {
|
||||
if (!item.deleted && item.content.getRef() === 6) { // is a ContentFormat
|
||||
const key = /** @type {ContentFormat} */ (item.content).key
|
||||
if (attrs.has(key)) {
|
||||
item.delete(transaction)
|
||||
transaction.cleanUps.add(item.id.client, item.id.clock, item.length)
|
||||
} else {
|
||||
attrs.add(key)
|
||||
}
|
||||
}
|
||||
item = item.left
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<string,any>} currentFormats
|
||||
* @param {ContentFormat} format
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const updateCurrentFormats = (currentFormats, { key, value }) => {
|
||||
if (value === null) {
|
||||
currentFormats.delete(key)
|
||||
} else {
|
||||
currentFormats.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this function after string content has been deleted in order to
|
||||
* clean up formatting Items.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} start
|
||||
* @param {Item|null} curr exclusive end, automatically iterates to the next Content Item
|
||||
* @param {Map<string,any>} startFormats
|
||||
* @param {Map<string,any>} currFormats
|
||||
* @return {number} The amount of formatting Items deleted.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const cleanupFormattingGap = (transaction, start, curr, startFormats, currFormats) => {
|
||||
if (!transaction.doc.cleanupFormatting) return 0
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let end = start
|
||||
/**
|
||||
* @type {Map<string,ContentFormat>}
|
||||
*/
|
||||
const endFormats = map.create()
|
||||
while (end && (!end.countable || end.deleted)) {
|
||||
if (!end.deleted && end.content.getRef() === 6) {
|
||||
const cf = /** @type {ContentFormat} */ (end.content)
|
||||
endFormats.set(cf.key, cf)
|
||||
}
|
||||
end = end.right
|
||||
}
|
||||
let cleanups = 0
|
||||
let reachedCurr = false
|
||||
while (start !== end) {
|
||||
if (curr === start) {
|
||||
reachedCurr = true
|
||||
}
|
||||
if (!start.deleted) {
|
||||
const content = start.content
|
||||
if (content.getRef() === 6) { // is ContentFormat
|
||||
const { key, value } = /** @type {ContentFormat} */ (content)
|
||||
const startFormatValue = startFormats.get(key) ?? null
|
||||
if (endFormats.get(key) !== content || startFormatValue === value) {
|
||||
// Either this format is overwritten or it is not necessary because the format already existed.
|
||||
start.delete(transaction)
|
||||
transaction.cleanUps.add(start.id.client, start.id.clock, start.length)
|
||||
cleanups++
|
||||
if (!reachedCurr && (currFormats.get(key) ?? null) === value && startFormatValue !== value) {
|
||||
if (startFormatValue === null) {
|
||||
currFormats.delete(key)
|
||||
} else {
|
||||
currFormats.set(key, startFormatValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reachedCurr && !start.deleted) {
|
||||
updateCurrentFormats(currFormats, /** @type {ContentFormat} */ (content))
|
||||
}
|
||||
}
|
||||
}
|
||||
start = /** @type {Item} */ (start.right)
|
||||
}
|
||||
return cleanups
|
||||
}
|
||||
576
src/utils/updates.js
Normal file
576
src/utils/updates.js
Normal file
@@ -0,0 +1,576 @@
|
||||
import * as binary from 'lib0/binary'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as error from 'lib0/error'
|
||||
import * as f from 'lib0/function'
|
||||
import * as logging from 'lib0/logging'
|
||||
import * as map from 'lib0/map'
|
||||
import * as string from 'lib0/string'
|
||||
|
||||
import { readIdSet, writeIdSet, createIdSet, intersectSets } from './ids.js'
|
||||
|
||||
import { createID } from './ID.js'
|
||||
import { IdSetEncoderV1, IdSetEncoderV2, UpdateEncoderV1, UpdateEncoderV2 } from './UpdateEncoder.js'
|
||||
import { UpdateDecoderV1, UpdateDecoderV2 } from './UpdateDecoder.js'
|
||||
import { GC } from '../structs/GC.js'
|
||||
import {
|
||||
Item,
|
||||
ContentAny,
|
||||
ContentBinary,
|
||||
ContentDeleted,
|
||||
ContentDoc,
|
||||
ContentEmbed,
|
||||
ContentFormat,
|
||||
ContentJSON,
|
||||
ContentString,
|
||||
ContentType
|
||||
} from '../structs/Item.js'
|
||||
import {
|
||||
readItemContent
|
||||
} from '../ynode.js'
|
||||
import { Skip } from '../structs/Skip.js'
|
||||
|
||||
/**
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||
*/
|
||||
function * lazyStructReaderGenerator (decoder) {
|
||||
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < numOfStateUpdates; i++) {
|
||||
const numberOfStructs = decoding.readVarUint(decoder.restDecoder)
|
||||
const client = decoder.readClient()
|
||||
let clock = decoding.readVarUint(decoder.restDecoder)
|
||||
for (let i = 0; i < numberOfStructs; i++) {
|
||||
const info = decoder.readInfo()
|
||||
// @todo use switch instead of ifs
|
||||
if (info === 10) {
|
||||
const len = decoding.readVarUint(decoder.restDecoder)
|
||||
yield new Skip(createID(client, clock), len)
|
||||
clock += len
|
||||
} else if ((binary.BITS5 & info) !== 0) {
|
||||
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||
// and we read the next string as parentYKey.
|
||||
// It indicates how we store/retrieve parent from `y.share`
|
||||
// @type {string|null}
|
||||
const struct = new Item(
|
||||
createID(client, clock),
|
||||
null, // left
|
||||
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
|
||||
null, // right
|
||||
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
|
||||
// @ts-ignore Force writing a string here.
|
||||
cantCopyParentInfo ? (decoder.readParentInfo() ? decoder.readString() : decoder.readLeftID()) : null, // parent
|
||||
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
|
||||
readItemContent(decoder, info) // item content
|
||||
)
|
||||
yield struct
|
||||
clock += struct.length
|
||||
} else {
|
||||
const len = decoder.readLen()
|
||||
yield new GC(createID(client, clock), len)
|
||||
clock += len
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LazyStructReader {
|
||||
/**
|
||||
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||
* @param {boolean} filterSkips
|
||||
*/
|
||||
constructor (decoder, filterSkips) {
|
||||
this.gen = lazyStructReaderGenerator(decoder)
|
||||
/**
|
||||
* @type {null | Item | Skip | GC}
|
||||
*/
|
||||
this.curr = null
|
||||
this.done = false
|
||||
this.filterSkips = filterSkips
|
||||
this.next()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Item | GC | Skip |null}
|
||||
*/
|
||||
next () {
|
||||
// ignore "Skip" structs
|
||||
do {
|
||||
this.curr = this.gen.next().value || null
|
||||
} while (this.filterSkips && this.curr !== null && this.curr.constructor === Skip)
|
||||
return this.curr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
*/
|
||||
export const logUpdate = update => logUpdateV2(update, UpdateDecoderV1)
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
|
||||
*/
|
||||
export const logUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
|
||||
const structs = []
|
||||
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||
const lazyDecoder = new LazyStructReader(updateDecoder, false)
|
||||
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||
structs.push(curr)
|
||||
}
|
||||
logging.print('Structs: ', structs)
|
||||
const ds = readIdSet(updateDecoder)
|
||||
logging.print('DeleteSet: ', ds)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
*/
|
||||
export const decodeUpdate = (update) => decodeUpdateV2(update, UpdateDecoderV1)
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
|
||||
*
|
||||
*/
|
||||
export const decodeUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
|
||||
const structs = []
|
||||
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||
const lazyDecoder = new LazyStructReader(updateDecoder, false)
|
||||
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||
structs.push(curr)
|
||||
}
|
||||
return {
|
||||
structs,
|
||||
ds: readIdSet(updateDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
export class LazyStructWriter {
|
||||
/**
|
||||
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||
*/
|
||||
constructor (encoder) {
|
||||
this.currClient = 0
|
||||
this.startClock = 0
|
||||
this.written = 0
|
||||
this.encoder = encoder
|
||||
/**
|
||||
* We want to write operations lazily, but also we need to know beforehand how many operations we want to write for each client.
|
||||
*
|
||||
* This kind of meta-information (#clients, #structs-per-client-written) is written to the restEncoder.
|
||||
*
|
||||
* We fragment the restEncoder and store a slice of it per-client until we know how many clients there are.
|
||||
* When we flush (toUint8Array) we write the restEncoder using the fragments and the meta-information.
|
||||
*
|
||||
* @type {Array<{ written: number, restEncoder: Uint8Array }>}
|
||||
*/
|
||||
this.clientStructs = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {typeof IdSetEncoderV1 | typeof IdSetEncoderV2} YEncoder
|
||||
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} YDecoder
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*/
|
||||
export const encodeStateVectorFromUpdateV2 = (update, YEncoder = IdSetEncoderV2, YDecoder = UpdateDecoderV2) => {
|
||||
const encoder = new YEncoder()
|
||||
const updateDecoder = new LazyStructReader(new YDecoder(decoding.createDecoder(update)), false)
|
||||
let curr = updateDecoder.curr
|
||||
if (curr !== null) {
|
||||
let size = 0
|
||||
let currClient = curr.id.client
|
||||
let stopCounting = curr.id.clock !== 0 // must start at 0
|
||||
let currClock = stopCounting ? 0 : curr.id.clock + curr.length
|
||||
for (; curr !== null; curr = updateDecoder.next()) {
|
||||
if (currClient !== curr.id.client) {
|
||||
if (currClock !== 0) {
|
||||
size++
|
||||
// We found a new client
|
||||
// write what we have to the encoder
|
||||
encoding.writeVarUint(encoder.restEncoder, currClient)
|
||||
encoding.writeVarUint(encoder.restEncoder, currClock)
|
||||
}
|
||||
currClient = curr.id.client
|
||||
currClock = 0
|
||||
stopCounting = curr.id.clock !== 0
|
||||
}
|
||||
// we ignore skips
|
||||
if (curr.constructor === Skip) {
|
||||
stopCounting = true
|
||||
}
|
||||
if (!stopCounting) {
|
||||
currClock = curr.id.clock + curr.length
|
||||
}
|
||||
}
|
||||
// write what we have
|
||||
if (currClock !== 0) {
|
||||
size++
|
||||
encoding.writeVarUint(encoder.restEncoder, currClient)
|
||||
encoding.writeVarUint(encoder.restEncoder, currClock)
|
||||
}
|
||||
// prepend the size of the state vector
|
||||
const enc = encoding.createEncoder()
|
||||
encoding.writeVarUint(enc, size)
|
||||
encoding.writeBinaryEncoder(enc, encoder.restEncoder)
|
||||
encoder.restEncoder = enc
|
||||
return encoder.toUint8Array()
|
||||
} else {
|
||||
encoding.writeVarUint(encoder.restEncoder, 0)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*/
|
||||
export const encodeStateVectorFromUpdate = update => encodeStateVectorFromUpdateV2(update, IdSetEncoderV1, UpdateDecoderV1)
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
|
||||
* @return {ContentIds}
|
||||
*/
|
||||
export const createContentIdsFromUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
|
||||
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||
const lazyDecoder = new LazyStructReader(updateDecoder, true)
|
||||
const inserts = createIdSet()
|
||||
let lastClientId = -1
|
||||
let lastClock = 0
|
||||
let lastLen = 0
|
||||
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||
const currId = curr.id
|
||||
if (lastClientId === currId.client && lastClock + lastLen === currId.clock) {
|
||||
// default case: extend prev entry
|
||||
lastLen += curr.length
|
||||
} else {
|
||||
if (lastClientId >= 0) {
|
||||
inserts.add(lastClientId, lastClock, lastLen)
|
||||
}
|
||||
lastClientId = currId.client
|
||||
lastClock = currId.clock
|
||||
lastLen = curr.length
|
||||
}
|
||||
}
|
||||
if (lastClientId >= 0) {
|
||||
inserts.add(lastClientId, lastClock, lastLen)
|
||||
}
|
||||
const deletes = readIdSet(updateDecoder)
|
||||
return { inserts, deletes }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @return {ContentIds}
|
||||
*/
|
||||
export const createContentIdsFromUpdate = update => createContentIdsFromUpdateV2(update, UpdateDecoderV1)
|
||||
|
||||
/**
|
||||
* This method is intended to slice any kind of struct and retrieve the right part.
|
||||
* It does not handle side-effects, so it should only be used by the lazy-encoder.
|
||||
*
|
||||
* @param {Item | GC | Skip} left
|
||||
* @param {number} diff
|
||||
* @return {Item | GC | Skip}
|
||||
*/
|
||||
export const sliceStruct = (left, diff) => {
|
||||
if (left.constructor === GC) {
|
||||
const { client, clock } = left.id
|
||||
return new GC(createID(client, clock + diff), left.length - diff)
|
||||
} else if (left.constructor === Skip) {
|
||||
const { client, clock } = left.id
|
||||
return new Skip(createID(client, clock + diff), left.length - diff)
|
||||
} else {
|
||||
const leftItem = /** @type {Item} */ (left)
|
||||
const { client, clock } = leftItem.id
|
||||
return new Item(
|
||||
createID(client, clock + diff),
|
||||
null,
|
||||
createID(client, clock + diff - 1),
|
||||
null,
|
||||
leftItem.rightOrigin,
|
||||
leftItem.parent,
|
||||
leftItem.parentSub,
|
||||
leftItem.content.splice(diff)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {LazyStructWriter} lazyWriter
|
||||
*/
|
||||
const flushLazyStructWriter = lazyWriter => {
|
||||
if (lazyWriter.written > 0) {
|
||||
lazyWriter.clientStructs.push({ written: lazyWriter.written, restEncoder: encoding.toUint8Array(lazyWriter.encoder.restEncoder) })
|
||||
lazyWriter.encoder.restEncoder = encoding.createEncoder()
|
||||
lazyWriter.written = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {LazyStructWriter} lazyWriter
|
||||
* @param {Item | GC | Skip} struct
|
||||
* @param {number} offset
|
||||
* @param {number} offsetEnd
|
||||
*/
|
||||
export const writeStructToLazyStructWriter = (lazyWriter, struct, offset, offsetEnd) => {
|
||||
// flush curr if we start another client
|
||||
if (lazyWriter.written > 0 && lazyWriter.currClient !== struct.id.client) {
|
||||
flushLazyStructWriter(lazyWriter)
|
||||
}
|
||||
if (lazyWriter.written === 0) {
|
||||
lazyWriter.currClient = struct.id.client
|
||||
// write next client
|
||||
lazyWriter.encoder.writeClient(struct.id.client)
|
||||
// write startClock
|
||||
encoding.writeVarUint(lazyWriter.encoder.restEncoder, struct.id.clock + offset)
|
||||
}
|
||||
struct.write(lazyWriter.encoder, offset, offsetEnd)
|
||||
lazyWriter.written++
|
||||
}
|
||||
/**
|
||||
* Call this function when we collected all parts and want to
|
||||
* put all the parts together. After calling this method,
|
||||
* you can continue using the UpdateEncoder.
|
||||
*
|
||||
* @param {LazyStructWriter} lazyWriter
|
||||
*/
|
||||
export const finishLazyStructWriting = (lazyWriter) => {
|
||||
flushLazyStructWriter(lazyWriter)
|
||||
|
||||
// this is a fresh encoder because we called flushCurr
|
||||
const restEncoder = lazyWriter.encoder.restEncoder
|
||||
|
||||
/**
|
||||
* Now we put all the fragments together.
|
||||
* This works similarly to `writeClientsStructs`
|
||||
*/
|
||||
|
||||
// write # states that were updated - i.e. the clients
|
||||
encoding.writeVarUint(restEncoder, lazyWriter.clientStructs.length)
|
||||
|
||||
for (let i = 0; i < lazyWriter.clientStructs.length; i++) {
|
||||
const partStructs = lazyWriter.clientStructs[i]
|
||||
/**
|
||||
* Works similarly to `writeStructs`
|
||||
*/
|
||||
// write # encoded structs
|
||||
encoding.writeVarUint(restEncoder, partStructs.written)
|
||||
// write the rest of the fragment
|
||||
encoding.writeUint8Array(restEncoder, partStructs.restEncoder)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {function(Item|GC|Skip):Item|GC|Skip} blockTransformer
|
||||
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} YDecoder
|
||||
* @param {typeof UpdateEncoderV2 | typeof UpdateEncoderV1 } YEncoder
|
||||
*/
|
||||
export const convertUpdateFormat = (update, blockTransformer, YDecoder, YEncoder) => {
|
||||
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||
const lazyDecoder = new LazyStructReader(updateDecoder, false)
|
||||
const updateEncoder = new YEncoder()
|
||||
const lazyWriter = new LazyStructWriter(updateEncoder)
|
||||
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||
writeStructToLazyStructWriter(lazyWriter, blockTransformer(curr), 0, 0)
|
||||
}
|
||||
finishLazyStructWriting(lazyWriter)
|
||||
const ds = readIdSet(updateDecoder)
|
||||
writeIdSet(updateEncoder, ds)
|
||||
return updateEncoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ObfuscatorOptions
|
||||
* @property {boolean} [ObfuscatorOptions.formatting=true]
|
||||
* @property {boolean} [ObfuscatorOptions.subdocs=true]
|
||||
* @property {boolean} [ObfuscatorOptions.name=true] Whether to obfuscate nodeName / hookName
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {ObfuscatorOptions} obfuscator
|
||||
*/
|
||||
const createObfuscator = ({ formatting = true, subdocs = true, name = true } = {}) => {
|
||||
let i = 0
|
||||
const mapKeyCache = map.create()
|
||||
const nodeNameCache = map.create()
|
||||
const formattingKeyCache = map.create()
|
||||
const formattingValueCache = map.create()
|
||||
formattingValueCache.set(null, null) // end of a formatting range should always be the end of a formatting range
|
||||
/**
|
||||
* @param {Item|GC|Skip} block
|
||||
* @return {Item|GC|Skip}
|
||||
*/
|
||||
return block => {
|
||||
switch (block.constructor) {
|
||||
case GC:
|
||||
case Skip:
|
||||
return block
|
||||
case Item: {
|
||||
const item = /** @type {Item} */ (block)
|
||||
const content = item.content
|
||||
switch (content.constructor) {
|
||||
case ContentDeleted:
|
||||
break
|
||||
case ContentType: {
|
||||
if (name) {
|
||||
const type = /** @type {ContentType} */ (content).type
|
||||
if (type.name != null) {
|
||||
type.name = map.setIfUndefined(nodeNameCache, type.name, () => 'typename-' + i)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContentAny: {
|
||||
const c = /** @type {ContentAny} */ (content)
|
||||
c.arr = c.arr.map(() => i)
|
||||
break
|
||||
}
|
||||
case ContentBinary: {
|
||||
const c = /** @type {ContentBinary} */ (content)
|
||||
c.content = new Uint8Array([i])
|
||||
break
|
||||
}
|
||||
case ContentDoc: {
|
||||
const c = /** @type {ContentDoc} */ (content)
|
||||
if (subdocs) {
|
||||
c.opts = {}
|
||||
c.guid = i + ''
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContentEmbed: {
|
||||
const c = /** @type {ContentEmbed} */ (content)
|
||||
c.embed = {}
|
||||
break
|
||||
}
|
||||
case ContentFormat: {
|
||||
const c = /** @type {ContentFormat} */ (content)
|
||||
if (formatting) {
|
||||
c.key = map.setIfUndefined(formattingKeyCache, c.key, () => i + '')
|
||||
c.value = map.setIfUndefined(formattingValueCache, c.value, () => ({ i }))
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContentJSON: {
|
||||
const c = /** @type {ContentJSON} */ (content)
|
||||
c.arr = c.arr.map(() => i)
|
||||
break
|
||||
}
|
||||
case ContentString: {
|
||||
const c = /** @type {ContentString} */ (content)
|
||||
c.str = string.repeat((i % 10) + '', c.str.length)
|
||||
break
|
||||
}
|
||||
default:
|
||||
// unknown content type
|
||||
error.unexpectedCase()
|
||||
}
|
||||
if (item.parentSub) {
|
||||
item.parentSub = map.setIfUndefined(mapKeyCache, item.parentSub, () => i + '')
|
||||
}
|
||||
i++
|
||||
return block
|
||||
}
|
||||
default:
|
||||
// unknown block-type
|
||||
error.unexpectedCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function obfuscates the content of a Yjs update. This is useful to share
|
||||
* buggy Yjs documents while significantly limiting the possibility that a
|
||||
* developer can on the user. Note that it might still be possible to deduce
|
||||
* some information by analyzing the "structure" of the document or by analyzing
|
||||
* the typing behavior using the CRDT-related metadata that is still kept fully
|
||||
* intact.
|
||||
*
|
||||
* @param {Uint8Array} update
|
||||
* @param {ObfuscatorOptions} [opts]
|
||||
*/
|
||||
export const obfuscateUpdate = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV1, UpdateEncoderV1)
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {ObfuscatorOptions} [opts]
|
||||
*/
|
||||
export const obfuscateUpdateV2 = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV2, UpdateEncoderV2)
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
*/
|
||||
export const convertUpdateFormatV1ToV2 = update => convertUpdateFormat(update, f.id, UpdateDecoderV1, UpdateEncoderV2)
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
*/
|
||||
export const convertUpdateFormatV2ToV1 = update => convertUpdateFormat(update, f.id, UpdateDecoderV2, UpdateEncoderV1)
|
||||
|
||||
/**
|
||||
* Filter an update to only include content specified by a ContentIds pattern.
|
||||
*
|
||||
* This function extracts a subset of an update, keeping only the structs whose IDs
|
||||
* are present in `contentIds.inserts` and only the delete set entries that are
|
||||
* present in `contentIds.deletes`.
|
||||
*
|
||||
* Note: If a struct partially overlaps with the contentIds pattern, only the
|
||||
* overlapping portion is included in the result.
|
||||
*
|
||||
* @param {Uint8Array} update
|
||||
* @param {ContentIds} contentIds - Pattern specifying which content to include
|
||||
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*/
|
||||
export const intersectUpdateWithContentIdsV2 = (update, contentIds, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
|
||||
const { inserts, deletes } = contentIds
|
||||
const encoder = new YEncoder()
|
||||
const lazyStructWriter = new LazyStructWriter(encoder)
|
||||
const decoder = new YDecoder(decoding.createDecoder(update))
|
||||
const reader = new LazyStructReader(decoder, true)
|
||||
|
||||
while (reader.curr) {
|
||||
const currClientId = reader.curr.id.client
|
||||
let nextClock = reader.curr.id.clock
|
||||
let firstWrite = false
|
||||
while (reader.curr != null && reader.curr.id.client === currClientId) {
|
||||
const curr = reader.curr
|
||||
for (const slice of inserts.slice(currClientId, curr.id.clock, curr.length)) {
|
||||
if (slice.exists) {
|
||||
const skipLen = slice.clock - nextClock
|
||||
if (skipLen > 0 && firstWrite) {
|
||||
// write missing skip
|
||||
writeStructToLazyStructWriter(lazyStructWriter, new Skip(createID(currClientId, nextClock), skipLen), 0, 0)
|
||||
}
|
||||
// write sliced content
|
||||
writeStructToLazyStructWriter(lazyStructWriter, curr, slice.clock - curr.id.clock, (curr.id.clock + curr.length) - (slice.clock + slice.len))
|
||||
nextClock = slice.clock + slice.len
|
||||
firstWrite = true
|
||||
}
|
||||
}
|
||||
reader.next()
|
||||
}
|
||||
}
|
||||
finishLazyStructWriting(lazyStructWriter)
|
||||
// Filter the delete set to only include entries in contentIds.deletes
|
||||
const ds = readIdSet(decoder)
|
||||
const filteredDs = intersectSets(ds, deletes)
|
||||
writeIdSet(encoder, filteredDs)
|
||||
return encoder.toUint8Array()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an update (V1 format) to only include content specified by a ContentIds pattern.
|
||||
*
|
||||
* @param {Uint8Array} update
|
||||
* @param {ContentIds} contentIds - Pattern specifying which content to include
|
||||
* @return {Uint8Array<ArrayBuffer>}
|
||||
*/
|
||||
export const intersectUpdateWithContentIds = (update, contentIds) =>
|
||||
intersectUpdateWithContentIdsV2(update, contentIds, UpdateDecoderV1, UpdateEncoderV1)
|
||||
2824
src/ynode.js
Normal file
2824
src/ynode.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user