[attribution] fixes for suggestion support in y-quill

This commit is contained in:
Kevin Jahns
2025-05-24 01:14:21 +02:00
parent 45920631de
commit 52441cbb27
6 changed files with 57 additions and 114 deletions

View File

@@ -123,6 +123,8 @@ Showcase](https://yjs-diagram.synergy.codes/).
* [Open Collaboration Tools](https://www.open-collab.tools/) - Collaborative
editing for your IDE or custom editor
* [Typst](https://typst.app/) - Compose, edit, and automate technical documents
* [ProtonMail | Proton Docs](https://proton.me/drive/docs) - E2E encrypted
collaborative documents in Proton Drive.
## Table of Contents

View File

@@ -117,6 +117,7 @@ export {
iterateStructsByIdSet,
createAttributionManagerFromDiff,
createIdSet,
mergeIdSets,
cloneDoc
} from './internals.js'

View File

@@ -130,13 +130,14 @@ export class ItemTextListPosition {
}
break
}
default:
default: {
const rightLen = this.am.contentLength(this.right)
if (length < rightLen) {
getItemCleanStart(transaction, createID(this.right.id.client, this.right.id.clock + length))
}
length -= rightLen
break
}
}
this.forward()
}
@@ -648,8 +649,8 @@ export class YTextEvent extends YEvent {
* @public
*/
getDelta (am = noAttributionsManager) {
const whatToWatch = mergeIdSets([diffIdSet(this.transaction.insertSet, this.transaction.deleteSet), diffIdSet(this.transaction.deleteSet, this.transaction.insertSet)])
return this.target.getDelta(am, whatToWatch)
const itemsToRender = mergeIdSets([diffIdSet(this.transaction.insertSet, this.transaction.deleteSet), diffIdSet(this.transaction.deleteSet, this.transaction.insertSet)])
return this.target.getDelta(am, { itemsToRender, retainDeletes: true })
}
/**
@@ -846,12 +847,6 @@ export class YText extends AbstractType {
}
/**
* Render the difference to another ydoc (which can be empty) and highlight the differences with
* attributions.
*
* Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the
* attribution `{ isDeleted: true, .. }`.
*
* @param {AbstractAttributionManager} am
* @return {import('../utils/Delta.js').TextDelta<Embeds>} The Delta representation of this type.
*
@@ -859,92 +854,25 @@ export class YText extends AbstractType {
*/
getContent (am = noAttributionsManager) {
return this.getDelta(am)
// this.doc ?? warnPrematureAccess()
// /**
// * @type {delta.TextDelta<Embeds>}
// */
// const d = delta.createTextDelta()
// /**
// * @type {Array<import('../internals.js').AttributedContent<any>>}
// */
// const cs = []
// for (let item = this._start; item !== null; cs.length = 0) {
// // populate cs
// for (; item !== null && cs.length < 50; item = item.right) {
// am.readContent(cs, item, false)
// }
// for (let i = 0; i < cs.length; i++) {
// const { content, deleted, attrs } = cs[i]
// const { attribution, retainOnly } = createAttributionFromAttributionItems(attrs, deleted)
// switch (content.constructor) {
// case ContentString: {
// if (retainOnly) {
// d.retain(content.getLength(), null, attribution)
// } else {
// d.insert(/** @type {ContentString} */ (content).str, null, attribution)
// }
// break
// }
// case ContentType:
// case ContentEmbed: {
// if (retainOnly) {
// d.retain(content.getLength(), null, attribution)
// } else {
// d.insert(/** @type {ContentEmbed | ContentType} */ (content).getContent()[0], null, attribution)
// }
// break
// }
// case ContentFormat: {
// const contentFormat = /** @type {ContentFormat} */ (content)
// if (attribution != null) {
// /**
// * @type {import('../utils/Delta.js').Attribution}
// */
// const formattingAttribution = object.assign({}, d.usedAttribution)
// const attributesChanged = /** @type {{ [key: string]: Array<any> }} */ (formattingAttribution.attributes = object.assign({}, formattingAttribution.attributes ?? {}))
// if (contentFormat.value === null) {
// delete attributesChanged[contentFormat.key]
// } else {
// const by = attributesChanged[contentFormat.key] = attributesChanged[contentFormat.key]?.slice() ?? []
// by.push(...((deleted ? attribution.delete : attribution.insert) ?? []))
// const attributedAt = (deleted ? attribution.deletedAt : attribution.insertedAt)
// if (attributedAt) formattingAttribution.attributedAt = attributedAt
// }
// if (object.isEmpty(attributesChanged)) {
// d.useAttribution(null)
// } else {
// const attributedAt = (deleted ? attribution.deletedAt : attribution.insertedAt)
// if (attributedAt != null) formattingAttribution.attributedAt = attributedAt
// d.useAttribution(formattingAttribution)
// }
// }
// if (!deleted) {
// const currAttrs = d.usedAttributes
// if (contentFormat.value == null) {
// const nextAttrs = object.assign({}, currAttrs)
// delete nextAttrs[contentFormat.key]
// d.useAttributes(nextAttrs)
// } else {
// d.useAttributes(object.assign({}, currAttrs, { [contentFormat.key]: contentFormat.value }))
// }
// }
// break
// }
// }
// }
// }
// return d
}
/**
* Render the difference to another ydoc (which can be empty) and highlight the differences with
* attributions.
*
* Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the
* attribution `{ isDeleted: true, .. }`.
*
* @param {AbstractAttributionManager} am
* @param {import('../utils/IdSet.js').IdSet?} itemsToRender
* @param {boolean} retainOnly - if true, retain the rendered items with attributes and attributions.
* @param {Object} [opts]
* @param {import('../utils/IdSet.js').IdSet?} [opts.itemsToRender]
* @param {boolean} [opts.retainInserts] - if true, retain rendered inserts with attributions
* @param {boolean} [opts.retainDeletes] - if true, retain rendered+attributed deletes only
* @return {import('../utils/Delta.js').TextDelta<Embeds>} The Delta representation of this type.
*
* @public
*/
getDelta (am = noAttributionsManager, itemsToRender = null, retainOnly = false) {
getDelta (am = noAttributionsManager, { itemsToRender = null, retainInserts = false, retainDeletes = false } = {}) {
/**
* @type {import('../utils/Delta.js').TextDelta<Embeds>}
*/
@@ -972,7 +900,7 @@ export class YText extends AbstractType {
if (itemsToRender != null) {
for (; item !== null && cs.length < 50; item = item.right) {
const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length)
let itemContent = rslice.length > 1 ? item.content.copy() : item.content
const itemContent = rslice.length > 1 ? item.content.copy() : item.content
for (let ir = 0; ir < rslice.length; ir++) {
const idrange = rslice[ir]
const content = itemContent
@@ -1003,10 +931,10 @@ export class YText extends AbstractType {
if (renderContent) {
d.usedAttributes = currentAttributes
usingCurrentAttributes = true
if (!retainOnly) {
d.insert(c.content.getContent()[0], null, attribution)
} else {
if (c.deleted ? retainDeletes : retainInserts) {
d.retain(c.content.getLength(), null, attribution)
} else {
d.insert(c.content.getContent()[0], null, attribution)
}
} else if (renderDelete) {
d.delete(1)
@@ -1020,10 +948,10 @@ export class YText extends AbstractType {
if (renderContent) {
d.usedAttributes = currentAttributes
usingCurrentAttributes = true
if (!retainOnly) {
d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
} else {
if (c.deleted ? retainDeletes : retainInserts) {
d.retain(/** @type {ContentString} */ (c.content).str.length, null, attribution)
} else {
d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
}
} else if (renderDelete) {
d.delete(c.content.getLength())
@@ -1302,7 +1230,7 @@ export class YText extends AbstractType {
}
/**
* @param {this} other
* @param {this} other
*/
[traits.EqualityTraitSymbol] (other) {
return this.getContent().equals(other.getContent())

View File

@@ -5,7 +5,6 @@ import {
createDeleteSetFromStructStore,
createIdMapFromIdSet,
ContentDeleted,
Item, Snapshot, Doc, AbstractContent, IdMap, // eslint-disable-line
insertIntoIdMap,
insertIntoIdSet,
diffIdMap,
@@ -13,9 +12,12 @@ import {
createAttributionItem,
mergeIdMaps,
createID,
mergeIdSets,
IdSet, Item, Snapshot, Doc, AbstractContent, IdMap // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import { ObservableV2 } from 'lib0/observable'
/**
* @typedef {Object} Attribution
@@ -92,8 +94,13 @@ export class AttributedContent {
/**
* Abstract class for associating Attributions to content / changes
*
* Should fire an event when the attributions changed _after_ the original change happens. This
* Event will be used to update the attribution on the current content.
*
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
*/
export class AbstractAttributionManager {
export class AbstractAttributionManager extends ObservableV2 {
/**
* @param {Array<AttributedContent<any>>} _contents - where to write the result
* @param {number} _client
@@ -116,25 +123,24 @@ export class AbstractAttributionManager {
contentLength (_item) {
error.methodUnimplemented()
}
destroy () {}
}
/**
* @implements AbstractAttributionManager
*
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
*/
export class TwosetAttributionManager {
export class TwosetAttributionManager extends ObservableV2 {
/**
* @param {IdMap<any>} inserts
* @param {IdMap<any>} deletes
*/
constructor (inserts, deletes) {
super()
this.inserts = inserts
this.deletes = deletes
}
destroy () {}
/**
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} client
@@ -174,10 +180,10 @@ export class TwosetAttributionManager {
* Abstract class for associating Attributions to content / changes
*
* @implements AbstractAttributionManager
*
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
*/
export class NoAttributionsManager {
destroy () {}
export class NoAttributionsManager extends ObservableV2 {
/**
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} _client
@@ -205,13 +211,16 @@ export const noAttributionsManager = new NoAttributionsManager()
/**
* @implements AbstractAttributionManager
*
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
*/
export class DiffAttributionManager {
export class DiffAttributionManager extends ObservableV2 {
/**
* @param {Doc} prevDoc
* @param {Doc} nextDoc
*/
constructor (prevDoc, nextDoc) {
super()
const _nextDocInserts = createInsertionSetFromStructStore(nextDoc.store, false) // unmaintained
const _prevDocInserts = createInsertionSetFromStructStore(prevDoc.store, false) // unmaintained
const nextDocDeletes = createDeleteSetFromStructStore(nextDoc.store) // maintained
@@ -227,7 +236,7 @@ export class DiffAttributionManager {
const diffInserts = diffIdSet(tr.insertSet, _prevDocInserts)
insertIntoIdMap(this.inserts, createIdMapFromIdSet(diffInserts, []))
// update deletes
const diffDeletes = diffIdSet(tr.deleteSet, prevDocDeletes)
const diffDeletes = diffIdSet(diffIdSet(tr.deleteSet, prevDocDeletes), this.inserts)
insertIntoIdMap(this.deletes, createIdMapFromIdSet(diffDeletes, []))
// @todo fire update ranges on `diffInserts` and `diffDeletes`
})
@@ -249,12 +258,14 @@ export class DiffAttributionManager {
this.deletes = diffIdMap(this.deletes, tr.deleteSet)
}
// @todo fire update ranges on `tr.insertSet` and `tr.deleteSet`
this.emit('change', [mergeIdSets([tr.insertSet, tr.deleteSet]), tr.origin, tr.local])
})
this._destroyHandler = nextDoc.on('destroy', this.destroy.bind(this))
prevDoc.on('destroy', this._destroyHandler)
}
destroy () {
super.destroy()
this._nextDoc.off('destroy', this._destroyHandler)
this._prevDoc.off('destroy', this._destroyHandler)
this._nextDoc.off('beforeObserverCalls', this._nextBOH)
@@ -324,13 +335,16 @@ export const createAttributionManagerFromDiff = (prevDoc, nextDoc) => new DiffAt
* read content similar to the previous snapshot api. Requires that `ydoc.gc` is turned off.
*
* @implements AbstractAttributionManager
*
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
*/
export class SnapshotAttributionManager {
export class SnapshotAttributionManager extends ObservableV2 {
/**
* @param {Snapshot} prevSnapshot
* @param {Snapshot} nextSnapshot
*/
constructor (prevSnapshot, nextSnapshot) {
super()
this.prevSnapshot = prevSnapshot
this.nextSnapshot = nextSnapshot
const inserts = createIdMap()
@@ -343,8 +357,6 @@ export class SnapshotAttributionManager {
this.attrs = mergeIdMaps([diffIdMap(inserts, prevSnapshot.ds), deletes])
}
destroy () { }
/**
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} client

View File

@@ -413,7 +413,7 @@ export class DeltaBuilder extends AbstractDelta {
* @return {this}
*/
done () {
while (this.lastOp != null && this.lastOp instanceof RetainOp && this.lastOp.attributes === null) {
while (this.lastOp != null && this.lastOp instanceof RetainOp && this.lastOp.attributes === null && this.lastOp.attribution === null) {
this.ops.pop()
this.lastOp = this.ops[this.ops.length - 1] ?? null
}

View File

@@ -20,7 +20,7 @@ export const testAttributedEvents = _tc => {
ydoc.transact(() => {
ytext.delete(6, 5)
})
let am = Y.createAttributionManagerFromDiff(v1, ydoc)
const am = Y.createAttributionManagerFromDiff(v1, ydoc)
const c1 = ytext.getDelta(am)
t.compare(c1, delta.createTextDelta().insert('hello ').insert('world', null, { delete: [] }))
let calledObserver = false
@@ -44,7 +44,7 @@ export const testInsertionsMindingAttributedContent = _tc => {
ydoc.transact(() => {
ytext.delete(6, 5)
})
let am = Y.createAttributionManagerFromDiff(v1, ydoc)
const am = Y.createAttributionManagerFromDiff(v1, ydoc)
const c1 = ytext.getDelta(am)
t.compare(c1, delta.createTextDelta().insert('hello ').insert('world', null, { delete: [] }))
ytext.applyDelta(delta.createTextDelta().retain(11).insert('content'), am)
@@ -62,7 +62,7 @@ export const testInsertionsIntoAttributedContent = _tc => {
ydoc.transact(() => {
ytext.insert(6, 'word')
})
let am = Y.createAttributionManagerFromDiff(v1, ydoc)
const am = Y.createAttributionManagerFromDiff(v1, ydoc)
const c1 = ytext.getDelta(am)
t.compare(c1, delta.createTextDelta().insert('hello ').insert('word', null, { insert: [] }))
ytext.applyDelta(delta.createTextDelta().retain(9).insert('l'), am)