From 2f7895e5ce3b90173a52c3f87528ac734ff1eb58 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Thu, 30 Oct 2025 03:26:24 +0100 Subject: [PATCH] fixes and more tests for delta representation on abstract types --- package-lock.json | 8 +- package.json | 2 +- src/structs/Item.js | 4 +- src/types/AbstractType.js | 25 ++++-- src/types/YArray.js | 12 --- src/types/YMap.js | 14 +--- src/types/YText.js | 4 - src/types/YXmlFragment.js | 11 --- src/utils/YEvent.js | 2 +- tests/delta.tests.js | 166 ++++++++++++++++++++++++++++++++++++++ tests/index.js | 3 +- 11 files changed, 197 insertions(+), 54 deletions(-) create mode 100644 tests/delta.tests.js diff --git a/package-lock.json b/package-lock.json index 20d48166..f09baa48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "14.0.0-10", "license": "MIT", "dependencies": { - "lib0": "^0.2.115-1" + "lib0": "^0.2.115-2" }, "devDependencies": { "@types/node": "^22.14.1", @@ -3478,9 +3478,9 @@ } }, "node_modules/lib0": { - "version": "0.2.115-1", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.115-1.tgz", - "integrity": "sha512-mmQ4Pk/wZBsjdGMUJtXBhPsqPZof6Eh9sqrApA2Ufqe2eFYWW4yQPZWdf5/ak+dXsRlbslLHrGAn7+MeOY3TGA==", + "version": "0.2.115-2", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.115-2.tgz", + "integrity": "sha512-LBe5bPJTGG9/7F+1Ax1moAHrHJ1TaaTQWw7J2t6L19yHN3U6uHBSUcIRsews1f6J7fiKWwoiNohGCebd96lnig==", "license": "MIT", "dependencies": { "isomorphic.js": "^0.2.4" diff --git a/package.json b/package.json index ce048e6e..62feded0 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ }, "homepage": "https://docs.yjs.dev", "dependencies": { - "lib0": "^0.2.115-1" + "lib0": "^0.2.115-2" }, "devDependencies": { "@types/node": "^22.14.1", diff --git a/src/structs/Item.js b/src/structs/Item.js index e4070304..137d328d 100644 --- a/src/structs/Item.js +++ b/src/structs/Item.js @@ -282,7 +282,7 @@ export class Item extends AbstractStruct { * @param {ID | null} origin * @param {Item | null} right * @param {ID | null} rightOrigin - * @param {YType__|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it. + * @param {AbstractType|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it. * @param {string | null} parentSub * @param {AbstractContent} content */ @@ -309,7 +309,7 @@ export class Item extends AbstractStruct { */ this.rightOrigin = rightOrigin /** - * @type {YType__|ID|null} + * @type {AbstractType|ID|null} */ this.parent = parent /** diff --git a/src/types/AbstractType.js b/src/types/AbstractType.js index 80da2b68..d8411933 100644 --- a/src/types/AbstractType.js +++ b/src/types/AbstractType.js @@ -275,7 +275,7 @@ export const callTypeObservers = (type, transaction, event) => { /** * Abstract Yjs Type class - * @template {delta.Delta} [EventDelta=delta.Delta] + * @template {delta.Delta} [EventDelta=any] * @template {AbstractType} [Self=any] */ export class AbstractType { @@ -392,9 +392,11 @@ export class AbstractType { * Must be implemented by each type. * * @param {Transaction} transaction - * @param {Set} _parentSubs Keys changed on this type. `null` if list was modified. + * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. */ - _callObserver (transaction, _parentSubs) { + _callObserver (transaction, parentSubs) { + const event = new YEvent(/** @type {any} */ (this), transaction, parentSubs) + callTypeObservers(/** @type {any} */ (this), transaction, event) if (!transaction.local && this._searchMarker) { this._searchMarker.length = 0 } @@ -738,6 +740,19 @@ export class AbstractType { error.unexpectedCase() } } + for (const op of d.attrs) { + if (delta.$insertOp.check(op)) { + typeMapSet(transaction, /** @type {any} */ (this), op.key, op.value) + } else if (delta.$deleteOp.check(op)) { + typeMapDelete(transaction, /** @type {any} */ (this), op.key) + } else { + const sub = typeMapGet(/** @type {any} */ (this), op.key) + if (!(sub instanceof AbstractType)) { + error.unexpectedCase() + } + sub.applyDelta(op.value) + } + } }) } } @@ -1201,7 +1216,7 @@ export const typeMapDelete = (transaction, parent, key) => { /** * @param {Transaction} transaction - * @param {YType_} parent + * @param {AbstractType} parent * @param {string} key * @param {_YValue} value * @@ -1244,7 +1259,7 @@ export const typeMapSet = (transaction, parent, key, value) => { } /** - * @param {YType_} parent + * @param {AbstractType} parent * @param {string} key * @return {Object|number|null|Array|string|Uint8Array|AbstractType|undefined} * diff --git a/src/types/YArray.js b/src/types/YArray.js index 82d17366..39e29fd6 100644 --- a/src/types/YArray.js +++ b/src/types/YArray.js @@ -14,7 +14,6 @@ import { typeListDelete, typeListMap, YArrayRefID, - callTypeObservers, transact, warnPrematureAccess, typeListSlice, @@ -101,17 +100,6 @@ export class YArray extends AbstractType { return this._length } - /** - * Creates YArrayEvent and calls observers. - * - * @param {Transaction} transaction - * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. - */ - _callObserver (transaction, parentSubs) { - super._callObserver(transaction, parentSubs) - callTypeObservers(this, transaction, new YEvent(this, transaction, parentSubs)) - } - /** * Inserts new content at an index. * diff --git a/src/types/YMap.js b/src/types/YMap.js index f9ff292a..b610c1df 100644 --- a/src/types/YMap.js +++ b/src/types/YMap.js @@ -3,7 +3,6 @@ */ import { - YEvent, AbstractType, typeMapDelete, typeMapSet, @@ -11,10 +10,9 @@ import { typeMapHas, createMapIterator, YMapRefID, - callTypeObservers, transact, warnPrematureAccess, - UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line + UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item // eslint-disable-line } from '../internals.js' import * as iterator from 'lib0/iterator' @@ -80,16 +78,6 @@ export class YMap extends AbstractType { return map } - /** - * Creates YMapEvent and calls observers. - * - * @param {Transaction} transaction - * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. - */ - _callObserver (transaction, parentSubs) { - callTypeObservers(this, transaction, new YEvent(this, transaction, parentSubs)) - } - /** * Transforms this Shared Type to a JSON object. * diff --git a/src/types/YText.js b/src/types/YText.js index dc50565f..3a45cd87 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -3,13 +3,11 @@ */ import { - YEvent, AbstractType, getItemCleanStart, getState, createID, YTextRefID, - callTypeObservers, transact, ContentEmbed, GC, @@ -705,8 +703,6 @@ export class YText extends AbstractType { */ _callObserver (transaction, parentSubs) { super._callObserver(transaction, parentSubs) - const event = new YEvent(/** @type {YText} */ (this), transaction, parentSubs) - callTypeObservers(/** @type {YText} */ (this), transaction, event) // If a remote change happened, we try to cleanup potential formatting duplicates. if (!transaction.local && this._hasFormatting) { transaction._needFormattingCleanup = true diff --git a/src/types/YXmlFragment.js b/src/types/YXmlFragment.js index 14d5586b..356f1934 100644 --- a/src/types/YXmlFragment.js +++ b/src/types/YXmlFragment.js @@ -12,7 +12,6 @@ import { typeListDelete, typeListToArray, YXmlFragmentRefID, - callTypeObservers, transact, typeListGet, typeListSlice, @@ -107,16 +106,6 @@ export class YXmlFragment extends AbstractType { return this._prelimContent === null ? this._length : this._prelimContent.length } - /** - * Creates YXmlEvent and calls observers. - * - * @param {Transaction} transaction - * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. - */ - _callObserver (transaction, parentSubs) { - callTypeObservers(this, transaction, new YEvent(this, transaction, parentSubs)) - } - /** * Get the string representation of all the children of this YXmlFragment. * diff --git a/src/utils/YEvent.js b/src/utils/YEvent.js index 5ee297c8..e209e80c 100644 --- a/src/utils/YEvent.js +++ b/src/utils/YEvent.js @@ -12,7 +12,7 @@ import * as delta from 'lib0/delta' // eslint-disable-line */ /** - * @template {_YType} Target + * @template {AbstractType} Target * YEvent describes the changes on a YType. */ export class YEvent { diff --git a/tests/delta.tests.js b/tests/delta.tests.js new file mode 100644 index 00000000..252c56bb --- /dev/null +++ b/tests/delta.tests.js @@ -0,0 +1,166 @@ +import * as Y from '../src/index.js' +import * as delta from 'lib0/delta' +import * as t from 'lib0/testing' + +/** + * Delta is a versatyle format enabling you to efficiently describe changes. It is part of lib0, so + * that non-yjs applications can use it without consuming the full Yjs package. It is well suited + * for efficiently describing state & changesets. + * + * Assume we start with the text "hello world". Now we want to delete " world" and add an + * exclamation mark. The final content should be "hello!" ("hello world" => "hello!") + * + * In most editors, you would describe the necessary changes as replace operations using indexes. + * However, this might become ambiguous when many changes are involved. + * + * - delete range 5-11 + * - insert "!" at position 11 + * + * Using the delta format, you can describe the changes similar to what you would do in an text editor. + * The "|" describes the current cursor position. + * + * - d.retain(5) - "|hello world" => "hello| world" - jump over the next five characters + * - d.delete(6) - "hello| world" => "hello|" - delete the next 6 characres + * - d.insert('!') - "hello!|" - insert "!" at the current position + * => compact form: d.retain(5).delete(6).insert('!') + * + * You can also apply the changes in two distinct steps and then rebase the op so that you can apply + * them in two distinct steps. + * - delete " world": d1 = delta.create().retain(5).delete(6) + * - insert "!": d2 = delta.create().retain(11).insert('!') + * - rebase d2 on-top of d1: d2.rebase(d1) == delta.create().retain(5).insert('!') + * - merge into a single change: d1.apply(d2) == delta.create().retain(5).delete(6).insert(!) + * + * @param {t.TestCase} _tc + */ +export const testDeltaBasics = _tc => { + // the state of our text document + const state = delta.create().insert('hello world') + // describe changes: delete " world" & insert "!" + const change = delta.create().retain(5).delete(6).insert('!') + // apply changes to state + state.apply(change) + // compare state to expected state + t.assert(state.equals(delta.create().insert('hello!'))) +} + +/** + * Deltas can describe changes on attributes and children. Textual insertions are children. But we + * may also insert json-objects and other deltas as children. + * Key-value pairs can be represented as attributes. This "convoluted" changeset enables us to + * describe many changes in the same breath: + * + * delta.create().set('a', 42).retain(5).delete(6).insert('!').unset('b') + * + * @param {t.TestCase} _tc + */ +export const testDeltaValues = _tc => { + const change = delta.create().set('a', 42).unset('b').retain(5).delete(6).insert('!').insert([{ my: 'custom object' }]) + // iterate through attribute changes + for (const attrChange of change.attrs) { + if (delta.$insertOp.check(attrChange)) { + console.log(`set ${attrChange.key} to ${attrChange.value}`) + } else if (delta.$deleteOp.check(attrChange)) { + console.log(`delete ${attrChange.key}`) + } + } + // iterate through child changes + for (const childChange of change.children) { + if (delta.$retainOp.check(childChange)) { + console.log(`retain ${childChange.retain} child items`) + } else if (delta.$deleteOp.check(childChange)) { + console.log(`delete ${childChange.delete} child items`) + } else if (delta.$insertOp.check(childChange)) { + console.log(`insert child items:`, childChange.insert) + } else if (delta.$textOp.check(childChange)) { + console.log(`insert textual content`, childChange.insert) + } + } +} + +/** + * The new delta defines changes on attributes (key-value) and child elements (list & text), but can + * also be used to describe the current state of a document. + * + * 1. apply a delta to change a yjs type + * 2. observe deltas to read the differences + * 3. merge deltas to reflect multiple changes in a single delta + * 4. All Yjs types fully support the delta format. It is no longer necessary to define the type (such as Y.Array) + * + * @param {t.TestCase} _tc + */ +export const testBasics = _tc => { + const ydoc = new Y.Doc() + const ytype = ydoc.get('my data') + /** + * @type {delta.Delta} + */ + let observedDelta = delta.create() + ytype.observe(event => { + observedDelta = event.deltaDeep + console.log('ytype changed:', observedDelta.toJSON()) + }) + // define a change: set attribute: a=42 + const attrChange = delta.create().set('a', 42).done() + // define a change: insert textual content and an object + const childChange = delta.create().insert('hello').insert([{ my: 'object' }]).done() + // merge changes + const mergedChanges = delta.create(delta.$deltaAny).apply(attrChange).apply(childChange).done() + console.log('merged changes: ', mergedChanges.toJSON()) + ytype.applyDelta(mergedChanges) + // the observed change should equal the applied change + t.assert(observedDelta.equals(mergedChanges)) + // read the current state of the yjs types as a delta + const currState = ytype.getContentDeep() + t.assert(currState.equals(mergedChanges)) // equal to the changes that we applied +} + +/** + * Deltas allow us to describe the differences between two Yjs documents though "Attributions". + * + * - We can attribute changes to a user, or a group of users + * - There are 'insert', 'delete', and 'format' attributions + * - When we render attributions, we render inserted & deleted content as an insertions with special + * attributes which allow you to.. + * -- Render deleted content using a strikethrough: I.e. `hello w̶o̶r̶l̶d̶!` + * -- Render attributed insertions using a background color. + * + * @param {t.TestCase} _tc + */ +export const testAttributions = _tc => { + const ydocV1 = new Y.Doc() + const ytypeV1 = ydocV1.get('txt') + ytypeV1.applyDelta(delta.create().insert('hello world')) + // create a new version with updated content + const ydoc = new Y.Doc() + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1)) + const ytype = ydoc.get('txt') + // delete " world" and insert exclamation mark "!". + ytype.applyDelta(delta.create().retain(5).delete(6).insert('!')) + const am = Y.createAttributionManagerFromDiff(ydocV1, ydoc) + // get the attributed differences + const attributedContent = ytype.getContent(am) + console.log('attributed content', attributedContent.toJSON()) + t.assert(attributedContent.equals(delta.create().insert('hello').insert(' world', null, { delete: [] }).insert('!', null, { insert: [] }))) + // for editor bindings, it is also necessary to observe changes and get the attributed changes + ytype.observe(event => { + const attributedChange = event.getDelta(am) + console.log('the attributed change', attributedChange.toJSON()) + t.assert(attributedChange.equals(delta.create().retain(11).insert('!', null, { insert: [] }))) + const unattributedChange = event.delta + console.log('the UNattributed change', unattributedChange.toJSON()) + t.assert(unattributedChange.equals(delta.create().retain(5).insert('!'))) + }) + /** + * Content now has different representations. + * - The UNattributed representation renders the latest state, without history. + * - The attributed representation renders the differences. + * + * Attributed: 'hello world!' + * UNattributed: 'world!' + */ + // Apply a change to the attributed content + ytype.applyDelta(delta.create().retain(11).insert('!'), am) + // // Equivalent to applying a change to the UNattributed content: + // ytype.applyDelta(delta.create().retain(5).insert('!')) +} diff --git a/tests/index.js b/tests/index.js index 9bd2894d..83b536e0 100644 --- a/tests/index.js +++ b/tests/index.js @@ -14,6 +14,7 @@ import * as relativePositions from './relativePositions.tests.js' import * as idset from './IdSet.tests.js' import * as idmap from './IdMap.tests.js' import * as attribution from './attribution.tests.js' +import * as delta from './delta.tests.js' import { runTests } from 'lib0/testing' import { isBrowser, isNode } from 'lib0/environment' @@ -24,7 +25,7 @@ if (isBrowser) { } const tests = { - doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions, idset, idmap, attribution + doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions, idset, idmap, attribution, delta } const run = async () => {