applyDelta accepts an origin paramiter - conforming to RDT interface

This commit is contained in:
Kevin Jahns
2026-07-02 18:44:46 +02:00
parent d6a217ea5d
commit 59cb523552
6 changed files with 127 additions and 104 deletions

View File

@@ -687,9 +687,9 @@ assign properties to ranges in the text. This makes it possible to implement
rich-text bindings to this type.
</p>
<p>
This type can also be transformed to the
<a href="https://quilljs.com/docs/delta">delta format</a>. Similarly the
YTextEvents compute changes as deltas.
This type can also be transformed to a delta
(<a href="https://github.com/dmonad/lib0">lib0/delta</a>). Similarly the
events compute changes as deltas.
</p>
<pre>const ytext = new Y.Text()</pre>
<dl>
@@ -704,11 +704,17 @@ YTextEvents compute changes as deltas.
<dd></dd>
<b><code>format(index:number, length:number, formattingAttributes:Object&lt;string,string&gt;)</code></b>
<dd>Assign formatting attributes to a range in the text</dd>
<b><code>applyDelta(delta: Delta, opts:Object&lt;string,any&gt;)</code></b>
<b><code>applyDelta(delta: Delta, [origin: any], [opts: {renderer: AbstractRenderer}])</code></b>
<dd>
See <a href="https://quilljs.com/docs/delta/">Quill Delta</a>
Can set options for preventing remove ending newLines, default is true.
<pre>ytext.applyDelta(delta, { sanitize: false })</pre>
Apply a delta (<a href="https://github.com/dmonad/lib0">lib0/delta</a>)
on this shared type. The optional <var>origin</var> is stored on the
transaction (<code>transaction.origin</code>) and forwarded verbatim
on the emitted <code>'delta'</code> event, so listeners can recognize
— and skip — changes they produced themselves (see the lib0
<code>RDT</code> spec). <code>opts.renderer</code> renders the content
with attributions; positions in the delta are then interpreted
relative to the attributed content.
<pre>ytext.applyDelta(delta.create().retain(5).insert('!').done(), origin)</pre>
</dd>
<b><code>length:number</code></b>
<dd></dd>
@@ -716,9 +722,10 @@ YTextEvents compute changes as deltas.
<dd>Transforms this type, without formatting options, into a string.</dd>
<b><code>toJSON():string</code></b>
<dd>See <code>toString</code></dd>
<b><code>toDelta():Delta</code></b>
<b><code>toDelta([opts: { renderer: AbstractRenderer }]):Delta</code></b>
<dd>
Transforms this type to a <a href="https://quilljs.com/docs/delta/">Quill Delta</a>
Transforms this type to a delta (<a href="https://github.com/dmonad/lib0">lib0/delta</a>),
optionally rendering attributed content with <code>opts.renderer</code>.
</dd>
<b><code>observe(function(YTextEvent, Transaction):void)</code></b>
<dd>

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
/**

View File

@@ -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('!'))
}