From a43f1983c5103b3d9ac66b88d2fc16d68f7c2c4b Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Wed, 30 Apr 2025 23:17:15 +0200 Subject: [PATCH] [AttributionManager] auto-updates on doc changes and can destroy itself --- src/utils/AttributionManager.js | 54 +++++++++++++++------ src/utils/IdMap.js | 14 +++++- src/utils/IdSet.js | 30 +++++++----- tests/y-xml.tests.js | 85 +++++++++++++++++++++++---------- 4 files changed, 131 insertions(+), 52 deletions(-) diff --git a/src/utils/AttributionManager.js b/src/utils/AttributionManager.js index 4b53bb16..e85f7a56 100644 --- a/src/utils/AttributionManager.js +++ b/src/utils/AttributionManager.js @@ -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} inserts - * @param {IdMap} 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) diff --git a/src/utils/IdMap.js b/src/utils/IdMap.js index 5f674182..9b168717 100644 --- a/src/utils/IdMap.js +++ b/src/utils/IdMap.js @@ -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} dest + * @param {IdMap} 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`. diff --git a/src/utils/IdSet.js b/src/utils/IdSet.js index 3d155163..393c91b2 100644 --- a/src/utils/IdSet.js +++ b/src/utils/IdSet.js @@ -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} 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} exclude - * @return {Set} + * @type {(idSet: IdSet, exclude: IdSet|IdMap) => IdSet} */ export const diffIdSet = _diffSet diff --git a/tests/y-xml.tests.js b/tests/y-xml.tests.js index 80bb2493..121e289b 100644 --- a/tests/y-xml.tests.js +++ b/tests/y-xml.tests.js @@ -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') }) }