From 59cb5235525df2ba95d2defb3fb602cfedba2994 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Thu, 2 Jul 2026 18:44:46 +0200 Subject: [PATCH] applyDelta accepts an origin paramiter - conforming to RDT interface --- README.md | 25 +++++---- attributing-content.md | 104 ++++++++++++++++++++----------------- renderer.md | 75 +++++++++++++------------- src/ytype.js | 11 ++-- tests/attribution.tests.js | 14 +++-- tests/delta.tests.js | 2 +- 6 files changed, 127 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 3c5e7ca4..71be4a6a 100644 --- a/README.md +++ b/README.md @@ -687,9 +687,9 @@ assign properties to ranges in the text. This makes it possible to implement rich-text bindings to this type.

-This type can also be transformed to the -delta format. Similarly the -YTextEvents compute changes as deltas. +This type can also be transformed to a delta +(lib0/delta). Similarly the +events compute changes as deltas.

const ytext = new Y.Text()
@@ -704,11 +704,17 @@ YTextEvents compute changes as deltas.
format(index:number, length:number, formattingAttributes:Object<string,string>)
Assign formatting attributes to a range in the text
- applyDelta(delta: Delta, opts:Object<string,any>) + applyDelta(delta: Delta, [origin: any], [opts: {renderer: AbstractRenderer}])
- See Quill Delta - Can set options for preventing remove ending newLines, default is true. -
ytext.applyDelta(delta, { sanitize: false })
+ Apply a delta (lib0/delta) + on this shared type. The optional origin is stored on the + transaction (transaction.origin) and forwarded verbatim + on the emitted 'delta' event, so listeners can recognize + — and skip — changes they produced themselves (see the lib0 + RDT spec). opts.renderer renders the content + with attributions; positions in the delta are then interpreted + relative to the attributed content. +
ytext.applyDelta(delta.create().retain(5).insert('!').done(), origin)
length:number
@@ -716,9 +722,10 @@ YTextEvents compute changes as deltas.
Transforms this type, without formatting options, into a string.
toJSON():string
See toString
- toDelta():Delta + toDelta([opts: { renderer: AbstractRenderer }]):Delta
-Transforms this type to a Quill Delta +Transforms this type to a delta (lib0/delta), +optionally rendering attributed content with opts.renderer.
observe(function(YTextEvent, Transaction):void)
diff --git a/attributing-content.md b/attributing-content.md index 5f45f8d3..3492f84e 100644 --- a/attributing-content.md +++ b/attributing-content.md @@ -26,7 +26,7 @@ this: ``` In Yjs, we can now "attribute" changes with additional information. When we -render content using methods like `toString()` or `getDelta()`, Yjs will render +render content using methods like `toString()` or `toDelta()`, Yjs will render the unattributed content as-is, but it will render the attributed content with the additional information. As all changes in Yjs are identifyable by Ids, we can use `IdMap`s to map changes to "attributions". For example, we could @@ -35,69 +35,77 @@ attribute deletions and insertions of a change and render them: ```js // We create some initial content "Hello World!". Then we create another // document that will have a bunch of changes (make "Hell" italic, replace "World" -// with "Attribution"). +// with "attributions"). const ydocVersion0 = new Y.Doc({ gc: false }) -ydocVersion0.getText().insert(0, 'Hello World!') +ydocVersion0.get().insert(0, 'Hello World!') const ydoc = new Y.Doc({ gc: false }) Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocVersion0)) -const ytext = ydoc.getText() -ytext.applyDelta([{ retain: 4, attributes: { italic: true } }, { retain: 2 }, { delete: 5 }, { insert: 'attributions' }]) -// this represents to all insertions of ydoc -const insertionSet = Y.createInsertionSetFromStructStore(ydoc.store) +const ytext = ydoc.get() +ytext.applyDelta(delta.create().retain(4, { italic: true }).retain(2).delete(5).insert('attributions').done()) +// this represents all insertions of ydoc +const insertionSet = Y.createInsertSetFromStructStore(ydoc.store, false) const deleteSet = Y.createDeleteSetFromStructStore(ydoc.store) // exclude the changes from `ydocVersion0` -const insertionSetDiff = Y.diffIdSet(insertionSet, Y.createInsertionSetFromStructStore(ydocVersion0.store)) +const insertionSetDiff = Y.diffIdSet(insertionSet, Y.createInsertSetFromStructStore(ydocVersion0.store, false)) const deleteSetDiff = Y.diffIdSet(deleteSet, Y.createDeleteSetFromStructStore(ydocVersion0.store)) // assign attributes to the diff -const attributedInsertions = createIdMapFromIdSet(insertionSetDiff, [new Y.Attribution('insert', 'Bob')]) -const attributedDeletions = createIdMapFromIdSet(deleteSetDiff, [new Y.Attribution('delete', 'Bob')]) +const attributedInsertions = Y.createIdMapFromIdSet(insertionSetDiff, [Y.createContentAttribute('insert', 'Bob')]) +const attributedDeletions = Y.createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute('delete', 'Bob')]) // now we can define a renderer that maps these changes to output. One of the // implementations is the TwosetRenderer -const renderer = new TwosetRenderer(attributedInsertions, attributedDeletions) +const renderer = new Y.TwosetRenderer(attributedInsertions, attributedDeletions) // we render the attributed content with the renderer -let attributedContent = ytext.toDelta({ renderer }) -console.log(JSON.stringify(attributedContent.toJSON().ops, null, 2)) -let expectedContent = delta.create().insert('Hell', { italic: true }, { attributes: { italic: ['Bob'] } }).insert('o ').insert('World', {}, { delete: ['Bob'] }).insert('attributions', {}, { insert: ['Bob'] }).insert('!') +const attributedContent = ytext.toDelta({ renderer }) +console.log(JSON.stringify(attributedContent.toJSON(), null, 2)) +const expectedContent = delta.create().insert('Hell', { italic: true }, { format: { italic: ['Bob'] } }).insert('o ').insert('World', {}, { delete: ['Bob'] }).insert('attributions', {}, { insert: ['Bob'] }).insert('!') t.assert(attributedContent.equals(expectedContent)) // this is how the output would look like -const output = [ - { - "insert": "Hell", - "attributes": { - "italic": true +const output = { + "type": "delta", + "children": [ + { + "type": "insert", + "insert": "Hell", + "format": { + "italic": true + }, + "attribution": { // no "insert" attribution: the insertion "Hell" is not attributed to anyone + "format": { + "italic": [ // the formatting attribute "italic" was added by Bob + "Bob" + ] + } + } }, - "attribution": { // no "insert" attribution: the insertion "Hell" is not attributed to anyone - "attributes": { - "italic": [ // the attribute "italic" was added by Bob + { + "type": "insert", + "insert": "o " // the insertion "o " has no attributions + }, + { + "type": "insert", + "insert": "World", + "attribution": { // the insertion "World" was deleted by Bob + "delete": [ "Bob" ] } + }, + { + "type": "insert", + "insert": "attributions", // the insertion "attributions" was inserted by Bob + "attribution": { + "insert": [ + "Bob" + ] + } + }, + { + "type": "insert", + "insert": "!" // the insertion "!" has no attributions } - }, - { - "insert": "o " // the insertion "o " has no attributions - }, - { - "insert": "World", - "attribution": { // the insertion "World" was deleted by Bob - "delete": [ - "Bob" - ] - } - }, - { - "insert": "attributions", // the insertion "attributions" was inserted by Bob - "attribution": { - "insert": [ - "Bob" - ] - } - }, - { - "insert": "!" // the insertion "!" has no attributions - } -] + ] +} ``` We get a similar output to Google Docs: Insertions, Deletions, and changes to @@ -107,7 +115,7 @@ the editor to render those changes with background-color etc.. Of course, we could associated changes also to multiple users like this: ```js -const attributedDeletions = createIdMapFromIdSet(deleteSetDiff, [new Y.Attribution('insert', 'Bob'), new Y.Attribution('insert', 'OpenAI o3')]) +const attributedDeletions = Y.createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute('insert', 'Bob'), Y.createContentAttribute('insert', 'OpenAI o3')]) ``` You could use the same output to calculate a real diff as well (consisting of @@ -119,4 +127,4 @@ this approach. The attribution data is encoded very efficiently. The ids are encoded using run-length encoding and the Attributes are de-duplicated and only encoded once. -The above example encodes in 20 bytes. +The above example encodes in 27 bytes. diff --git a/renderer.md b/renderer.md index 194538d3..2ea34b1a 100644 --- a/renderer.md +++ b/renderer.md @@ -48,70 +48,67 @@ Deleted content is represented in attributed results to maintain authorship info ## API Reference -### YText +### Y.Type -#### `getDelta([renderer])` +In Yjs v14 all shared types (text, array, map, xml) are instances of the unified +`Y.Type`. -Returns the delta representation of the YText content, optionally with attribution information. +#### `toDelta([{ renderer }])` + +Returns the delta representation (lib0/delta) of the type's content, optionally +with attribution information. **Parameters:** - `renderer` (optional): The renderer instance **Returns:** -- Array of delta operations, with attribution metadata if `renderer` is provided +- A `Delta` describing the content, with attribution metadata if `renderer` is provided **Examples:** ```javascript -const ytext = new Y.Text() +const ytext = ydoc.get() // Content is inserted during collaborative editing // Attribution is handled automatically by the server // Without attribution -const delta = ytext.getDelta() +const d = ytext.toDelta() // [{ insert: 'hello world' }] // With attribution -const attributedDelta = ytext.getDelta({ renderer }) +const attributedDelta = ytext.toDelta({ renderer }) // [ // { insert: 'hello', attribution: { insert: ['kevin'] } }, // { insert: ' world', attribution: { insert: ['alice'] } } // ] ``` -#### `toDelta([renderer])` +#### `applyDelta(delta, [origin], [{ renderer }])` -Returns the content representation with optional attribution information. +Applies a delta (lib0/delta) on the shared type. The optional `origin` is stored +on the transaction (`transaction.origin`) and forwarded verbatim on the emitted +`'delta'` event (lib0 RDT spec), so listeners can recognize — and skip — changes +they produced themselves. When a `renderer` is provided, positions in the delta +are interpreted relative to the attributed (rendered) content. + +**Parameters:** +- `delta`: The changes to apply +- `origin` (optional): Origin of the transaction that applies this delta; defaults to `null` +- `renderer` (optional): The renderer instance + +### YEvent + +#### `getDelta([{ renderer, deep }])` + +Returns the changes of an event as a delta, optionally rendered with attribution +information. **Parameters:** - `renderer` (optional): The renderer instance +- `deep` (optional): Render child types as deltas **Returns:** -- Content representation with attribution metadata if `renderer` is provided - -### YArray - -#### `toDelta([renderer])` - -Returns the array content with optional attribution information for each element. - -**Parameters:** -- `renderer` (optional): The renderer instance - -**Returns:** -- Array content with attribution metadata if `renderer` is provided - -### YMap - -#### `toDelta([renderer])` - -Returns the map content with optional attribution information for each key-value pair. - -**Parameters:** -- `renderer` (optional): The renderer instance - -**Returns:** -- Map content with attribution metadata if `renderer` is provided +- A `Delta` describing the changes, with attribution metadata if `renderer` is provided ## Position Adjustments @@ -124,7 +121,7 @@ When working with attributed content, position calculations must account for del ytext.toString() // "world" // Attributed content (includes deleted content) -ytext.getDelta({ renderer }) +ytext.toDelta({ renderer }) // [ // { insert: 'hello ', attribution: { delete: ['kevin'] } }, // positions 0-5 // { insert: 'world' } // positions 6-10 @@ -176,9 +173,9 @@ Display content with visual indicators of who created each part: ```javascript function renderWithAuthorship(ytext, renderer) { - const attributedDelta = ytext.getDelta({ renderer }) + const attributedDelta = ytext.toDelta({ renderer }) - return attributedDelta.map(op => { + return attributedDelta.children.map(op => { const author = op.attribution?.insert?.[0] || 'unknown' const isDeleted = op.attribution?.delete @@ -199,9 +196,9 @@ Track who made specific changes to content: ```javascript function trackChanges(ytext, renderer) { ytext.observe((event, transaction) => { - const changes = event.changes.getAttributedDelta?.(renderer) || event.changes.delta + const changes = event.getDelta({ renderer }) - changes.forEach(change => { + changes.children.forEach(change => { if (change.attribution) { console.log(`Change by ${change.attribution.insert?.[0] || change.attribution.delete?.[0]}:`, change) } diff --git a/src/ytype.js b/src/ytype.js index a018ba33..856267e6 100644 --- a/src/ytype.js +++ b/src/ytype.js @@ -1238,6 +1238,9 @@ export class YType extends ObservableV2 { * Apply a {@link Delta} on this shared type. * * @param {delta.DeltaAny} d The changes to apply on this element. + * @param {any} [origin] Origin of the transaction that applies this delta (stored on + * `transaction.origin` and forwarded verbatim on the emitted `'delta'` event, so listeners can + * recognize — and skip — changes they produced themselves; see the lib0 `RDT` spec). Defaults to `null`. * @param {Object} [opts] * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YType#useRenderer}), i.e. `baseRenderer` unless changed * @return {null} The lib0 `RDT` "fix" of this apply — always `null`: a `YType` accepts every valid @@ -1245,7 +1248,7 @@ export class YType extends ObservableV2 { * * @public */ - applyDelta (d, { renderer = this._renderer } = {}) { + applyDelta (d, origin = null, { renderer = this._renderer } = {}) { if (d.isEmpty()) return null if (this.doc == null) { (this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d) @@ -1266,7 +1269,7 @@ export class YType extends ObservableV2 { let item = currPos.right while (item != null && (item.deleted || !item.countable)) { item = item.next } if (item == null || item.content.constructor !== ContentType) { error.unexpectedCase() } - /** @type {ContentType} */ (item.content).type.applyDelta(op.value, { renderer }) + /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer }) currPos.formatText(transaction, /** @type {any} */ (this), 1, op.format || {}) } else { error.unexpectedCase() @@ -1282,10 +1285,10 @@ export class YType extends ObservableV2 { if (!(sub instanceof YType)) { error.unexpectedCase() } - sub.applyDelta(op.value, { renderer }) + sub.applyDelta(op.value, origin, { renderer }) } } - }) + }, origin) } return null } diff --git a/tests/attribution.tests.js b/tests/attribution.tests.js index 87d81344..7bb6e606 100644 --- a/tests/attribution.tests.js +++ b/tests/attribution.tests.js @@ -51,7 +51,7 @@ export const testAttributedEvents = _tc => { t.compare(d, delta.create().retain(11).insert('!', null, { insert: [] }).done()) calledObserver = true }) - ytext.applyDelta(delta.create().retain(11).insert('!').done(), { renderer }) + ytext.applyDelta(delta.create().retain(11).insert('!').done(), null, { renderer }) t.assert(calledObserver) } @@ -69,7 +69,7 @@ export const testInsertionsMindingAttributedContent = _tc => { const renderer = Y.createDiffRenderer(v1, ydoc) const c1 = ytext.toDelta({ renderer }) t.compare(c1, delta.create().insert('hello ').insert('world', null, { delete: [] }).done()) - ytext.applyDelta(delta.create().retain(11).insert('content').done(), { renderer }) + ytext.applyDelta(delta.create().retain(11).insert('content').done(), null, { renderer }) t.assert(ytext.toString() === 'hello content') } @@ -87,7 +87,7 @@ export const testInsertionsIntoAttributedContent = _tc => { const renderer = Y.createDiffRenderer(v1, ydoc) const c1 = ytext.toDelta({ renderer }) t.compare(c1, delta.create().insert('hello ').insert('word', null, { insert: [] }).done()) - ytext.applyDelta(delta.create().retain(9).insert('l').done(), { renderer }) + ytext.applyDelta(delta.create().retain(9).insert('l').done(), null, { renderer }) t.assert(ytext.toString() === 'hello world') } @@ -276,6 +276,14 @@ export const testRdtDeltaEventOrigin = () => { capturedOrigin = myOrigin ytext.insert(5, ' world') t.assert(capturedOrigin === null) + // the origin passed to `applyDelta` becomes the transaction origin and is forwarded on the event + const applyOrigin = {} + ytext.applyDelta(delta.create().retain(11).insert('!').done(), applyOrigin) + t.assert(capturedOrigin === applyOrigin) + // `applyDelta` without an explicit origin emits `null` + capturedOrigin = applyOrigin + ytext.applyDelta(delta.create().retain(12).insert('?').done()) + t.assert(capturedOrigin === null) } /** diff --git a/tests/delta.tests.js b/tests/delta.tests.js index f802d67e..85497966 100644 --- a/tests/delta.tests.js +++ b/tests/delta.tests.js @@ -191,7 +191,7 @@ export const testAttributions = _tc => { * UNattributed: 'world!' */ // Apply a change to the attributed content - ytype.applyDelta(delta.create().retain(11).insert('!').done(), { renderer }) + ytype.applyDelta(delta.create().retain(11).insert('!').done(), null, { renderer }) // // Equivalent to applying a change to the UNattributed content: // ytype.applyDelta(delta.create().retain(5).insert('!')) }