[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
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
run-length encoding and the Attributes are de-duplicated and only encoded once.
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.
*
* @param {YText} type
* @param {YText<any>} type
* @return {number} How many formatting attributes have been cleaned up.
*/
export const cleanupYTextFormatting = type => {
@@ -485,7 +485,7 @@ export const cleanupYTextFormatting = type => {
*/
export const cleanupYTextAfterTransaction = transaction => {
/**
* @type {Set<YText>}
* @type {Set<YText<any>>}
*/
const needFullCleanup = new Set()
// check if another formatting item was inserted
@@ -500,10 +500,10 @@ export const cleanupYTextAfterTransaction = transaction => {
// cleanup in a new transaction
transact(doc, (t) => {
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
}
const parent = /** @type {YText} */ (item.parent)
const parent = /** @type {YText<any>} */ (item.parent)
if (item.content.constructor === ContentFormat) {
needFullCleanup.add(parent)
} 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.
*/
export class YTextEvent extends YEvent {
/**
* @param {YText} ytext
* @param {YText<TextEmbeds>} ytext
* @param {Transaction} transaction
* @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 () {
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 = {
keys: this.keys,
@@ -642,7 +643,7 @@ export class YTextEvent extends YEvent {
* Compute the changes in the delta format.
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
*
* @type {delta.TextDelta}
* @type {delta.TextDelta<TextEmbeds>}
*
* @public
*/
@@ -754,6 +755,7 @@ export class YTextEvent extends YEvent {
* block formats (format information on a paragraph), embeds (complex elements
* like pictures and videos), and text formats (**bold**, *italic*).
*
* @template {any} Embeds
* @extends AbstractType<YTextEvent>
*/
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.
*
* @return {YText}
* @return {YText<Embeds>}
*/
clone () {
const text = new YText()
@@ -916,14 +918,14 @@ export class YText extends AbstractType {
* attribution `{ isDeleted: true, .. }`.
*
* @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
*/
getContentDeep (am = noAttributionsManager) {
return this.getContent(am).map(d =>
d instanceof delta.InsertStringOp && d.insert instanceof AbstractType
? new delta.InsertStringOp(d.insert.getContent(am), d.attributes, d.attribution)
d instanceof delta.InsertEmbedOp && d.insert instanceof AbstractType
? new delta.InsertEmbedOp(d.insert.getContent(am), d.attributes, d.attribution)
: d
)
}
@@ -936,7 +938,7 @@ export class YText extends AbstractType {
* attribution `{ isDeleted: true, .. }`.
*
* @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
*/

View File

@@ -271,8 +271,9 @@ export class SnapshotAttributionManager {
const inserts = createIdMap()
const deletes = createIdMapFromIdSet(diffIdSet(nextSnapshot.ds, prevSnapshot.ds), [createAttributionItem('change', '')])
nextSnapshot.sv.forEach((clock, client) => {
inserts.add(client, 0, prevSnapshot.sv.get(client) || 0, [])
inserts.add(client, prevSnapshot.sv.get(client) || 0, clock, [createAttributionItem('change', '')])
const prevClock = prevSnapshot.sv.get(client) || 0
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])
}
@@ -289,10 +290,12 @@ export class SnapshotAttributionManager {
let content = slice.length === 1 ? item.content : item.content.copy()
slice.forEach(s => {
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
if (s.len < c.getLength()) {
content = c.splice(s.len)
}
if (nonExistend) return
if (!deleted || (s.attrs != null && s.attrs.length > 0)) {
let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null
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
*/

View File

@@ -465,7 +465,7 @@ export const compare = users => {
const userArrayValues = users.map(u => u.getArray('array').toJSON())
const userMapValues = users.map(u => u.getMap('map').toJSON())
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) {
t.assert(u.store.pendingDs === null)
t.assert(u.store.pendingStructs === null)

View File

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