[AttributionManager] auto-updates on doc changes and can destroy itself

This commit is contained in:
Kevin Jahns
2025-04-30 23:17:15 +02:00
parent 2daad96c12
commit a43f1983c5
4 changed files with 131 additions and 52 deletions

View File

@@ -5,7 +5,10 @@ import {
createDeleteSetFromStructStore,
createIdMapFromIdSet,
ContentDeleted,
Doc, Item, AbstractContent, IdMap // eslint-disable-line
Doc, Item, AbstractContent, IdMap, // eslint-disable-line
insertIntoIdMap,
insertIntoIdSet,
diffIdMap
} from '../internals.js'
import * as error from 'lib0/error'
@@ -146,16 +149,46 @@ export const noAttributionsManager = new NoAttributionsManager()
*/
export class DiffAttributionManager {
/**
* @param {IdMap<any>} inserts
* @param {IdMap<any>} deletes
* @param {Doc} prevDoc
* @param {Doc} nextDoc
*/
constructor (inserts, deletes, prevDoc, nextDoc) {
this.inserts = inserts
this.deletes = deletes
constructor (prevDoc, nextDoc) {
const nextDocInserts = createInsertionSetFromStructStore(nextDoc.store)
const prevDocInserts = createInsertionSetFromStructStore(prevDoc.store)
const nextDocDeletes = createDeleteSetFromStructStore(nextDoc.store)
const prevDocDeletes = createDeleteSetFromStructStore(prevDoc.store)
this.inserts = createIdMapFromIdSet(diffIdSet(nextDocInserts, prevDocInserts), [])
this.deletes = createIdMapFromIdSet(diffIdSet(nextDocDeletes, prevDocDeletes), [])
this._prevDoc = prevDoc
this._prevDocStore = prevDoc.store
this._nextDoc = nextDoc
// update before observer calls fired
this._nextBOH = nextDoc.on('beforeObserverCalls', tr => {
// update inserts
insertIntoIdSet(nextDocInserts, tr.insertSet)
const diffInserts = diffIdSet(tr.insertSet, prevDocInserts)
insertIntoIdMap(this.inserts, createIdMapFromIdSet(diffInserts, []))
// update deletes
insertIntoIdSet(nextDocDeletes, tr.deleteSet)
const diffDeletes = diffIdSet(tr.deleteSet, prevDocDeletes)
insertIntoIdMap(this.deletes, createIdMapFromIdSet(diffDeletes, []))
// @todo fire update ranges on `diffInserts` and `diffDeletes`
})
this._prevBOH = prevDoc.on('beforeObserverCalls', tr => {
this.inserts = diffIdMap(this.inserts, tr.insertSet)
this.deletes = diffIdMap(this.deletes, tr.deleteSet)
// @todo fire update ranges on `tr.insertSet` and `tr.deleteSet`
})
this._destroyHandler = nextDoc.on('destroy', this.destroy.bind(this))
prevDoc.on('destroy', this._destroyHandler)
}
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)
}
/**
@@ -198,11 +231,4 @@ export class DiffAttributionManager {
* @param {Doc} prevDoc
* @param {Doc} nextDoc
*/
export const createAttributionManagerFromDiff = (prevDoc, nextDoc) => {
const inserts = diffIdSet(createInsertionSetFromStructStore(nextDoc.store), createInsertionSetFromStructStore(prevDoc.store))
const deletes = diffIdSet(createDeleteSetFromStructStore(nextDoc.store), createDeleteSetFromStructStore(prevDoc.store))
const insertMap = createIdMapFromIdSet(inserts, [])
const deleteMap = createIdMapFromIdSet(deletes, [])
// @todo, get deletes from the older doc
return new DiffAttributionManager(insertMap, deleteMap, prevDoc, nextDoc)
}
export const createAttributionManagerFromDiff = (prevDoc, nextDoc) => new DiffAttributionManager(prevDoc, nextDoc)

View File

@@ -3,7 +3,8 @@ import {
findIndexInIdRanges,
findRangeStartInIdRanges,
_deleteRangeFromIdSet,
DSDecoderV1, DSDecoderV2, IdSetEncoderV1, IdSetEncoderV2, IdSet, ID // eslint-disable-line
DSDecoderV1, DSDecoderV2, IdSetEncoderV1, IdSetEncoderV2, IdSet, ID, // eslint-disable-line
_insertIntoIdSet
} from '../internals.js'
import * as array from 'lib0/array'
@@ -139,6 +140,10 @@ export class AttrRanges {
this._ids = ids
}
copy () {
return new AttrRanges(this._ids.slice())
}
/**
* @param {number} clock
* @param {number} length
@@ -572,6 +577,13 @@ const _ensureAttrs = (idmap, attrs) => attrs.map(attr =>
export const createIdMap = () => new IdMap()
/**
* @template T
* @param {IdMap<T>} dest
* @param {IdMap<T>} src
*/
export const insertIntoIdMap = _insertIntoIdSet
/**
* Remove all ranges from `exclude` from `ds`. The result is a fresh IdMap containing all ranges from `idSet` that are not
* in `exclude`.

View File

@@ -51,6 +51,10 @@ class IdRanges {
this._ids = ids
}
copy () {
return new IdRanges(this._ids.slice())
}
/**
* @param {number} clock
* @param {number} length
@@ -162,13 +166,13 @@ export const _deleteRangeFromIdSet = (set, client, clock, len) => {
if (index != null) {
for (let r = ids[index]; index < ids.length && r.clock < clock + len; r = ids[++index]) {
if (r.clock < clock) {
ids[index] = r.copyWith(r.clock, clock-r.clock)
ids[index] = r.copyWith(r.clock, clock - r.clock)
if (clock + len < r.clock + r.len) {
ids.splice(index + 1, 0, r.copyWith(clock + len, r.clock + r.len - clock - len))
}
} else if (clock + len < r.clock + r.len) {
// need to retain end
ids[index] = r.copyWith(clock + len , r.clock + r.len - clock - len)
ids[index] = r.copyWith(clock + len, r.clock + r.len - clock - len)
} else if (ids.length === 1) {
set.clients.delete(client)
return
@@ -283,23 +287,30 @@ export const mergeIdSets = idSets => {
}
/**
* @param {IdSet} dest
* @param {IdSet} src
* @template {IdSet | IdMap<any>} S
* @param {S} dest
* @param {S} src
*/
export const insertIntoIdSet = (dest, src) => {
export const _insertIntoIdSet = (dest, src) => {
src.clients.forEach((srcRanges, client) => {
const targetRanges = dest.clients.get(client)
if (targetRanges) {
array.appendTo(targetRanges.getIds(), srcRanges.getIds())
targetRanges.sorted = false
} else {
const res = new IdRanges(srcRanges.getIds().slice())
const res = srcRanges.copy()
res.sorted = true
dest.clients.set(client, res)
dest.clients.set(client, /** @type {any} */ (res))
}
})
}
/**
* @param {IdSet} dest
* @param {IdSet} src
*/
export const insertIntoIdSet = _insertIntoIdSet
/**
* Remove all ranges from `exclude` from `ds`. The result is a fresh IdSet containing all ranges from `idSet` that are not
* in `exclude`.
@@ -373,10 +384,7 @@ export const _diffSet = (set, exclude) => {
* Remove all ranges from `exclude` from `idSet`. The result is a fresh IdSet containing all ranges from `idSet` that are not
* in `exclude`.
*
* @template {IdSet} Set
* @param {Set} idSet
* @param {IdSet | IdMap<any>} exclude
* @return {Set}
* @type {(idSet: IdSet, exclude: IdSet|IdMap<any>) => IdSet}
*/
export const diffIdSet = _diffSet

View File

@@ -309,36 +309,69 @@ export const testElementAttributedContentViaDiffer = _tc => {
const yelement = ydoc.getXmlElement('p')
const elem2 = yelement.get(1) // new Y.XmlElement('span')
const elem3 = new Y.XmlText('world')
t.group('insert / delete', () => {
ydoc.transact(() => {
yelement.delete(0, 1)
yelement.insert(1, [elem3])
yelement.setAttribute('key', '42')
})
const attributionManager = Y.createAttributionManagerFromDiff(ydocV1, ydoc)
const expectedContent = delta.createArrayDelta().insert([delta.createTextDelta().insert('hello', null, { delete: [] })], null, { delete: [] }).insert([elem2.getContentDeep()]).insert([delta.createTextDelta().insert('world', null, { insert: [] })], null, { insert: [] })
ydoc.transact(() => {
yelement.delete(0, 1)
yelement.insert(1, [elem3])
yelement.setAttribute('key', '42')
})
const attributionManager = Y.createAttributionManagerFromDiff(ydocV1, ydoc)
const expectedContent = delta.createArrayDelta().insert([delta.createTextDelta().insert('hello', null, { delete: [] })], null, { delete: [] }).insert([elem2.getContentDeep()]).insert([delta.createTextDelta().insert('world', null, { insert: [] })], null, { insert: [] })
const attributedContent = yelement.getContentDeep(attributionManager)
console.log('children', attributedContent.children.toJSON().ops)
console.log('attributes', attributedContent.attributes)
t.assert(attributedContent.children.equals(expectedContent))
t.compare(attributedContent.attributes, { key: { prevValue: undefined, value: '42', attribution: { insert: [] } } })
t.group('test getContentDeep', () => {
const expectedContent = delta.createArrayDelta().insert(
[delta.createTextDelta().insert('hello', null, { delete: [] })],
null,
{ delete: [] }
).insert([{ nodeName: 'span', children: delta.createArrayDelta(), attributes: {} }])
.insert([
delta.createTextDelta().insert('world', null, { insert: [] })
], null, { insert: [] })
const attributedContent = yelement.getContentDeep(attributionManager)
console.log('children', attributedContent.children.toJSON().ops)
console.log('children', JSON.stringify(attributedContent.children.toJSON().ops, null, 2))
console.log('cs expec', JSON.stringify(expectedContent.toJSON().ops, null, 2))
console.log('attributes', attributedContent.attributes)
t.assert(attributedContent.children.equals(expectedContent))
t.compare(attributedContent.attributes, { key: { prevValue: undefined, value: '42', attribution: { insert: [] } } })
t.group('test getContentDeep', () => {
const expectedContent = delta.createArrayDelta().insert(
[delta.createTextDelta().insert('hello', null, { delete: [] })],
null,
{ delete: [] }
).insert([{ nodeName: 'span', children: delta.createArrayDelta(), attributes: {} }])
.insert([
delta.createTextDelta().insert('world', null, { insert: [] })
], null, { insert: [] })
const attributedContent = yelement.getContentDeep(attributionManager)
console.log('children', JSON.stringify(attributedContent.children.toJSON().ops, null, 2))
console.log('cs expec', JSON.stringify(expectedContent.toJSON().ops, null, 2))
console.log('attributes', attributedContent.attributes)
t.assert(attributedContent.children.equals(expectedContent))
t.compare(attributedContent.attributes, { key: { prevValue: undefined, value: '42', attribution: { insert: [] } } })
t.assert(attributedContent.nodeName === 'UNDEFINED')
})
t.assert(attributedContent.nodeName === 'UNDEFINED')
})
ydoc.transact(() => {
elem3.insert(0, 'big')
})
t.group('test getContentDeep after some more updates', () => {
t.info('expecting diffingAttributionManager to auto update itself')
const expectedContent = delta.createArrayDelta().insert(
[delta.createTextDelta().insert('hello', null, { delete: [] })],
null,
{ delete: [] }
).insert([{ nodeName: 'span', children: delta.createArrayDelta(), attributes: {} }])
.insert([
delta.createTextDelta().insert('bigworld', null, { insert: [] })
], null, { insert: [] })
const attributedContent = yelement.getContentDeep(attributionManager)
console.log('children', JSON.stringify(attributedContent.children.toJSON().ops, null, 2))
console.log('cs expec', JSON.stringify(expectedContent.toJSON().ops, null, 2))
console.log('attributes', attributedContent.attributes)
t.assert(attributedContent.children.equals(expectedContent))
t.compare(attributedContent.attributes, { key: { prevValue: undefined, value: '42', attribution: { insert: [] } } })
t.assert(attributedContent.nodeName === 'UNDEFINED')
})
Y.applyUpdate(ydocV1, Y.encodeStateAsUpdate(ydoc))
t.group('test getContentDeep both docs synced', () => {
t.info('expecting diffingAttributionManager to auto update itself')
const expectedContent = delta.createArrayDelta().insert([{ nodeName: 'span', children: delta.createArrayDelta(), attributes: {} }]).insert([
delta.createTextDelta().insert('bigworld')
])
const attributedContent = yelement.getContentDeep(attributionManager)
console.log('children', JSON.stringify(attributedContent.children.toJSON().ops, null, 2))
console.log('cs expec', JSON.stringify(expectedContent.toJSON().ops, null, 2))
console.log('attributes', attributedContent.attributes)
t.assert(attributedContent.children.equals(expectedContent))
t.compare(attributedContent.attributes, { key: { prevValue: undefined, value: '42', attribution: null } })
t.assert(attributedContent.nodeName === 'UNDEFINED')
})
}