[y.text] event returns delta - fix a bunch of bugs

This commit is contained in:
Kevin Jahns
2025-05-07 19:35:31 +02:00
parent 0efa4dd2a7
commit cb191e744e
6 changed files with 23 additions and 32 deletions

View File

@@ -116,16 +116,6 @@ deletions and insertions only, without Attributions).
`AttributionManager` is an abstract class for mapping attributions. It is `AttributionManager` is an abstract class for mapping attributions. It is
possible to highlight arbitrary content with this approach. possible to highlight arbitrary content with this approach.
The next steps are to:
- finish the implementation for Y.Map and Y.Xml* (which should be easy, compared
to Y.Map).
- Implement an AttributionManager-CRDT for the backend that sits there and
associates changes with users.
- use `getContent(attributionManager)` instead of `toDelta` in y-prosemirror.
Would like to make the attribution part of y-prosemirror, however Nick can also
use this approach to customly render the changes in ProseMirror.
The AttributionManager is encodes very efficiently. The ids are encoded using The AttributionManager is encodes very efficiently. The ids are encoded using
run-length encoding and the Attributes are de-duplicated and only encoded once. 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 20 bytes.

View File

@@ -448,7 +448,7 @@ const cleanupContextlessFormattingGap = (transaction, item) => {
* *
* This function won't be exported anymore as soon as there is confidence that the YText type works as intended. * This function won't be exported anymore as soon as there is confidence that the YText type works as intended.
* *
* @param {YText} type * @param {YText<any>} type
* @return {number} How many formatting attributes have been cleaned up. * @return {number} How many formatting attributes have been cleaned up.
*/ */
export const cleanupYTextFormatting = type => { export const cleanupYTextFormatting = type => {
@@ -485,7 +485,7 @@ export const cleanupYTextFormatting = type => {
*/ */
export const cleanupYTextAfterTransaction = transaction => { export const cleanupYTextAfterTransaction = transaction => {
/** /**
* @type {Set<YText>} * @type {Set<YText<any>>}
*/ */
const needFullCleanup = new Set() const needFullCleanup = new Set()
// check if another formatting item was inserted // check if another formatting item was inserted
@@ -500,10 +500,10 @@ export const cleanupYTextAfterTransaction = transaction => {
// cleanup in a new transaction // cleanup in a new transaction
transact(doc, (t) => { transact(doc, (t) => {
iterateStructsByIdSet(transaction, transaction.deleteSet, item => { iterateStructsByIdSet(transaction, transaction.deleteSet, item => {
if (item instanceof GC || !(/** @type {YText} */ (item.parent)._hasFormatting) || needFullCleanup.has(/** @type {YText} */ (item.parent))) { if (item instanceof GC || !(/** @type {YText<any>} */ (item.parent)._hasFormatting) || needFullCleanup.has(/** @type {YText<any>} */ (item.parent))) {
return return
} }
const parent = /** @type {YText} */ (item.parent) const parent = /** @type {YText<any>} */ (item.parent)
if (item.content.constructor === ContentFormat) { if (item.content.constructor === ContentFormat) {
needFullCleanup.add(parent) needFullCleanup.add(parent)
} else { } else {
@@ -588,12 +588,13 @@ const deleteText = (transaction, currPos, length) => {
*/ */
/** /**
* @extends YEvent<YText> * @template {{ [key:string]: any } | AbstractType<any> } TextEmbeds
* @extends YEvent<YText<any>>
* Event that describes the changes on a YText type. * Event that describes the changes on a YText type.
*/ */
export class YTextEvent extends YEvent { export class YTextEvent extends YEvent {
/** /**
* @param {YText} ytext * @param {YText<TextEmbeds>} ytext
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {Set<any>} subs The keys that changed * @param {Set<any>} subs The keys that changed
*/ */
@@ -620,12 +621,12 @@ export class YTextEvent extends YEvent {
} }
/** /**
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:delta.TextDelta}} * @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:delta.TextDelta<TextEmbeds>}}
*/ */
get changes () { get changes () {
if (this._changes === null) { if (this._changes === null) {
/** /**
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:delta.TextDelta}} * @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:delta.TextDelta<TextEmbeds>}}
*/ */
const changes = { const changes = {
keys: this.keys, keys: this.keys,
@@ -642,7 +643,7 @@ export class YTextEvent extends YEvent {
* Compute the changes in the delta format. * Compute the changes in the delta format.
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document. * A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
* *
* @type {delta.TextDelta} * @type {delta.TextDelta<TextEmbeds>}
* *
* @public * @public
*/ */
@@ -754,6 +755,7 @@ export class YTextEvent extends YEvent {
* block formats (format information on a paragraph), embeds (complex elements * block formats (format information on a paragraph), embeds (complex elements
* like pictures and videos), and text formats (**bold**, *italic*). * like pictures and videos), and text formats (**bold**, *italic*).
* *
* @template {any} Embeds
* @extends AbstractType<YTextEvent> * @extends AbstractType<YTextEvent>
*/ */
export class YText extends AbstractType { export class YText extends AbstractType {
@@ -811,7 +813,7 @@ export class YText extends AbstractType {
* *
* Note that the content is only readable _after_ it has been included somewhere in the Ydoc. * Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
* *
* @return {YText} * @return {YText<Embeds>}
*/ */
clone () { clone () {
const text = new YText() const text = new YText()
@@ -916,14 +918,14 @@ export class YText extends AbstractType {
* attribution `{ isDeleted: true, .. }`. * attribution `{ isDeleted: true, .. }`.
* *
* @param {AbstractAttributionManager} am * @param {AbstractAttributionManager} am
* @return {import('../utils/Delta.js').TextDelta<string | import('./AbstractType.js').DeepContent >} The Delta representation of this type. * @return {import('../utils/Delta.js').TextDelta< Embeds extends import('./AbstractType.js').AbstractType<any> ? import('./AbstractType.js').DeepContent : Embeds >} The Delta representation of this type.
* *
* @public * @public
*/ */
getContentDeep (am = noAttributionsManager) { getContentDeep (am = noAttributionsManager) {
return this.getContent(am).map(d => return this.getContent(am).map(d =>
d instanceof delta.InsertStringOp && d.insert instanceof AbstractType d instanceof delta.InsertEmbedOp && d.insert instanceof AbstractType
? new delta.InsertStringOp(d.insert.getContent(am), d.attributes, d.attribution) ? new delta.InsertEmbedOp(d.insert.getContent(am), d.attributes, d.attribution)
: d : d
) )
} }
@@ -936,7 +938,7 @@ export class YText extends AbstractType {
* attribution `{ isDeleted: true, .. }`. * attribution `{ isDeleted: true, .. }`.
* *
* @param {AbstractAttributionManager} am * @param {AbstractAttributionManager} am
* @return {import('../utils/Delta.js').TextDelta} The Delta representation of this type. * @return {import('../utils/Delta.js').TextDelta<Embeds>} The Delta representation of this type.
* *
* @public * @public
*/ */

View File

@@ -271,8 +271,9 @@ export class SnapshotAttributionManager {
const inserts = createIdMap() const inserts = createIdMap()
const deletes = createIdMapFromIdSet(diffIdSet(nextSnapshot.ds, prevSnapshot.ds), [createAttributionItem('change', '')]) const deletes = createIdMapFromIdSet(diffIdSet(nextSnapshot.ds, prevSnapshot.ds), [createAttributionItem('change', '')])
nextSnapshot.sv.forEach((clock, client) => { nextSnapshot.sv.forEach((clock, client) => {
inserts.add(client, 0, prevSnapshot.sv.get(client) || 0, []) const prevClock = prevSnapshot.sv.get(client) || 0
inserts.add(client, prevSnapshot.sv.get(client) || 0, clock, [createAttributionItem('change', '')]) inserts.add(client, 0, prevClock, []) // content is included in prevSnapshot is rendered without attributes
inserts.add(client, prevClock, clock - prevClock, [createAttributionItem('change', '')]) // content is rendered as "inserted"
}) })
this.attrs = mergeIdMaps([diffIdMap(inserts, prevSnapshot.ds), deletes]) this.attrs = mergeIdMaps([diffIdMap(inserts, prevSnapshot.ds), deletes])
} }
@@ -289,10 +290,12 @@ export class SnapshotAttributionManager {
let content = slice.length === 1 ? item.content : item.content.copy() let content = slice.length === 1 ? item.content : item.content.copy()
slice.forEach(s => { slice.forEach(s => {
const deleted = this.nextSnapshot.ds.has(item.id.client, s.clock) const deleted = this.nextSnapshot.ds.has(item.id.client, s.clock)
const nonExistend = (this.nextSnapshot.sv.get(item.id.client) ?? 0) <= s.clock
const c = content const c = content
if (s.len < c.getLength()) { if (s.len < c.getLength()) {
content = c.splice(s.len) content = c.splice(s.len)
} }
if (nonExistend) return
if (!deleted || (s.attrs != null && s.attrs.length > 0)) { if (!deleted || (s.attrs != null && s.attrs.length > 0)) {
let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null
if (s.attrs?.length === 0) { if (s.attrs?.length === 0) {

View File

@@ -192,10 +192,6 @@ export class RetainOp {
} }
} }
/**
* @typedef {string | { [key: string]: any }} TextDeltaContent
*/
/** /**
* @typedef {(TextDelta<any> | ArrayDelta<any>)} Delta * @typedef {(TextDelta<any> | ArrayDelta<any>)} Delta
*/ */

View File

@@ -465,7 +465,7 @@ export const compare = users => {
const userArrayValues = users.map(u => u.getArray('array').toJSON()) const userArrayValues = users.map(u => u.getArray('array').toJSON())
const userMapValues = users.map(u => u.getMap('map').toJSON()) const userMapValues = users.map(u => u.getMap('map').toJSON())
const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString()) const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString())
const userTextValues = users.map(u => u.getText('text').getContent()) const userTextValues = users.map(u => u.getText('text').getContentDeep())
for (const u of users) { for (const u of users) {
t.assert(u.store.pendingDs === null) t.assert(u.store.pendingDs === null)
t.assert(u.store.pendingStructs === null) t.assert(u.store.pendingStructs === null)

View File

@@ -1872,7 +1872,7 @@ export const testSnapshotDeleteAfter = tc => {
}, { }, {
insert: 'e' insert: 'e'
}]) }])
const state1 = text0.getContent(createAttributionManagerFromSnapshots(snapshot1, snapshot1)) const state1 = text0.getContent(createAttributionManagerFromSnapshots(snapshot1))
t.compare(state1, delta.fromJSON([{ insert: 'abcd' }])) t.compare(state1, delta.fromJSON([{ insert: 'abcd' }]))
} }