more generic gedDelta implementation (could be used for events)

This commit is contained in:
Kevin Jahns
2025-05-18 22:56:14 +02:00
parent b646654df1
commit 4d582748c1
5 changed files with 268 additions and 61 deletions

View File

@@ -514,15 +514,17 @@ export const typeListGetContent = (type, am) => {
for (let item = type._start; item !== null; cs.length = 0) {
// populate cs
for (; item !== null && cs.length < 50; item = item.right) {
am.readContent(cs, item, false)
am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, true)
}
for (let i = 0; i < cs.length; i++) {
const { content, deleted, attrs } = cs[i]
const { attribution, retainOnly } = createAttributionFromAttributionItems(attrs, deleted)
if (retainOnly) {
d.retain(content.getLength())
} else if (content.isCountable()) {
d.insert(content.getContent(), null, attribution)
const c = cs[i]
const attribution = createAttributionFromAttributionItems(c.attrs, c.deleted).attribution
if (c.content.isCountable()) {
if (c.render) {
d.insert(c.content.getContent(), null, attribution)
} else {
d.retain(c.content.getLength())
}
}
}
}
@@ -1009,7 +1011,7 @@ export const typeMapGetContent = (parent, am) => {
* @type {Array<import('../internals.js').AttributedContent<any>>}
*/
const cs = []
am.readContent(cs, item, false)
am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, true)
const { deleted, attrs, content } = cs[cs.length - 1]
const c = array.last(content.getContent())
const { attribution } = createAttributionFromAttributionItems(attrs, deleted)
@@ -1025,7 +1027,7 @@ export const typeMapGetContent = (parent, am) => {
* @type {Array<import('../internals.js').AttributedContent<any>>}
*/
const tmpcs = []
am.readContent(tmpcs, prevItem, false)
am.readContent(tmpcs, prevItem.id.client, prevItem.id.clock, prevItem.deleted, prevItem.content, true)
cs = tmpcs.concat(cs)
if (cs.length === 0 || cs[0].attrs == null) {
cs.splice(0, cs.findIndex(c => c.attrs != null))

View File

@@ -24,7 +24,7 @@ import {
updateMarkerChanges,
ContentType,
warnPrematureAccess,
IdSet, noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item, Transaction, // eslint-disable-line
noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item, Transaction, // eslint-disable-line
createAttributionFromAttributionItems
} from '../internals.js'
@@ -675,7 +675,7 @@ export class YTextEvent extends YEvent {
for (let item = this.target._start; item !== null; cs.length = 0, item = item.right) {
const freshDelete = item.deleted && tr.deleteSet.hasId(item.id) && !tr.insertSet.hasId(item.id)
const freshInsert = !item.deleted && tr.insertSet.hasId(item.id)
am.readContent(cs, item, freshDelete) // do item.right after calling this
am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, !item.deleted || freshDelete) // do item.right after calling this
for (let i = 0; i < cs.length; i++) {
const c = cs[i]
const { attribution } = createAttributionFromAttributionItems(c.attrs, c.deleted)
@@ -709,6 +709,7 @@ export class YTextEvent extends YEvent {
break
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (c.content)
// # update attributes
const currAttrVal = currentAttributes[key] ?? null
if (freshDelete || freshInsert) {
// create fresh references
@@ -744,6 +745,29 @@ export class YTextEvent extends YEvent {
currentAttributes[key] = value
previousAttributes[key] = value
}
// # Update Attributions
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 (value === null) {
delete attributesChanged[key]
} else {
const by = attributesChanged[key] = (attributesChanged[key]?.slice() ?? [])
by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? []))
const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt)
if (attributedAt) formattingAttribution.attributedAt = attributedAt
}
if (object.isEmpty(attributesChanged)) {
d.useAttribution(null)
} else {
const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt)
if (attributedAt != null) formattingAttribution.attributedAt = attributedAt
d.useAttribution(formattingAttribution)
}
}
break
}
}
@@ -967,6 +991,7 @@ export class YText extends AbstractType {
* @public
*/
getContent (am = noAttributionsManager) {
return this.getDelta(am)
this.doc ?? warnPrematureAccess()
/**
* @type {delta.TextDelta<Embeds>}
@@ -1044,6 +1069,168 @@ export class YText extends AbstractType {
return d
}
/**
* @param {AbstractAttributionManager} am
* @param {import('../utils/IdSet.js').IdSet?} itemsToRender
* @param {boolean} retainOnly - if true, retain the rendered items with attributes and attributions.
* @return {import('../utils/Delta.js').TextDelta<Embeds>} The Delta representation of this type.
*
* @public
*/
getDelta (am = noAttributionsManager, itemsToRender = null, retainOnly = false) {
/**
* @type {import('../utils/Delta.js').TextDelta<Embeds>}
*/
const d = delta.createTextDelta()
/**
* @type {import('../utils/Delta.js').FormattingAttributes}
*/
let currentAttributes = {} // saves all current attributes for insert
let usingCurrentAttributes = false
/**
* @type {import('../utils/Delta.js').FormattingAttributes}
*/
let changedAttributes = {} // saves changed attributes for retain
let usingChangedAttributes = false
/**
* @type {import('../utils/Delta.js').FormattingAttributes}
*/
const previousAttributes = {} // The value before changes
/**
* @type {Array<import('../internals.js').AttributedContent<any>>}
*/
const cs = []
for (let item = this._start; item !== null; cs.length = 0) {
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
for (let ir = 0; ir < rslice.length; ir++) {
const idrange = rslice[ir]
const content = itemContent
if (ir !== rslice.length - 1) {
itemContent.splice(idrange.len)
}
am.readContent(cs, item.id.client, idrange.clock, item.deleted, content, idrange.exists)
}
}
} else {
for (; item !== null && cs.length < 50; item = item.right) {
am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, true)
}
}
for (let i = 0; i < cs.length; i++) {
const c = cs[i]
const renderDelete = c.deleted && c.attrs != null && c.render
const renderInsert = !c.deleted && (c.render || c.attrs != null)
const attribution = (renderDelete || renderInsert) ? createAttributionFromAttributionItems(c.attrs, c.deleted).attribution : null
switch (c.content.constructor) {
case ContentType:
case ContentEmbed:
if (renderInsert) {
d.usedAttributes = currentAttributes
usingCurrentAttributes = true
d.insert(c.content.getContent()[0], null, attribution)
} else if (renderDelete) {
d.delete(1)
} else if (!c.deleted) {
d.usedAttributes = changedAttributes
usingChangedAttributes = true
d.retain(1)
}
break
case ContentString:
if (renderInsert || (renderDelete && attribution?.delete != null)) {
d.usedAttributes = currentAttributes
usingCurrentAttributes = true
d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
} else if (renderDelete) {
d.delete(c.content.getLength())
} else if (!c.deleted) {
d.usedAttributes = changedAttributes
usingChangedAttributes = true
d.retain(c.content.getLength())
}
break
case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (c.content)
const currAttrVal = currentAttributes[key] ?? null
// # Update Attributes
if (renderDelete || renderInsert) {
// create fresh references
if (usingCurrentAttributes) {
currentAttributes = object.assign({}, currentAttributes)
usingCurrentAttributes = false
}
if (usingChangedAttributes) {
usingChangedAttributes = false
changedAttributes = object.assign({}, changedAttributes)
}
}
if (renderInsert) {
if (equalAttrs(value, currAttrVal)) {
// item.delete(transaction)
} else if (equalAttrs(value, previousAttributes[key] ?? null)) {
delete currentAttributes[key]
delete changedAttributes[key]
} else {
currentAttributes[key] = value
changedAttributes[key] = value
}
} else if (renderDelete) {
if (equalAttrs(value,currAttrVal)) {
delete changedAttributes[key]
delete currentAttributes[key]
} else {
changedAttributes[key] = currAttrVal
currentAttributes[key] = currAttrVal
}
previousAttributes[key] = value
} else if (!c.deleted) {
// fresh reference to currentAttributes only
if (usingCurrentAttributes) {
currentAttributes = object.assign({}, currentAttributes)
usingCurrentAttributes = false
}
if (equalAttrs(value, previousAttributes[key] ?? null)) {
delete currentAttributes[key]
} else {
currentAttributes[key] = value
}
previousAttributes[key] = value
}
// # Update Attributions
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 (value === null) {
delete attributesChanged[key]
} else {
const by = attributesChanged[key] = (attributesChanged[key]?.slice() ?? [])
by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? []))
const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt)
if (attributedAt) formattingAttribution.attributedAt = attributedAt
}
if (object.isEmpty(attributesChanged)) {
d.useAttribution(null)
} else {
const attributedAt = (c.deleted ? attribution.deletedAt : attribution.insertedAt)
if (attributedAt != null) formattingAttribution.attributedAt = attributedAt
d.useAttribution(formattingAttribution)
}
}
break
}
}
}
}
return d.done()
}
/**
* Insert text at a given index.
*

View File

@@ -5,13 +5,14 @@ import {
createDeleteSetFromStructStore,
createIdMapFromIdSet,
ContentDeleted,
Snapshot, Doc, Item, AbstractContent, IdMap, // eslint-disable-line
Snapshot, Doc, AbstractContent, IdMap, // eslint-disable-line
insertIntoIdMap,
insertIntoIdSet,
diffIdMap,
createIdMap,
createAttributionItem,
mergeIdMaps
mergeIdMaps,
createID
} from '../internals.js'
import * as error from 'lib0/error'
@@ -29,6 +30,7 @@ import * as error from 'lib0/error'
*/
/**
* @todo SHOULD NOT RETURN AN OBJECT!
* @param {Array<import('./IdMap.js').AttributionItem<any>>?} attrs
* @param {boolean} deleted - whether the attributed item is deleted
* @return {{ attribution: Attribution?, retainOnly: boolean }}
@@ -78,11 +80,13 @@ export class AttributedContent {
* @param {AbstractContent} content
* @param {boolean} deleted
* @param {Array<import('./IdMap.js').AttributionItem<T>> | null} attrs
* @param {boolean} render
*/
constructor (content, deleted, attrs) {
constructor (content, deleted, attrs, render) {
this.content = content
this.deleted = deleted
this.attrs = attrs
this.render = render
}
}
@@ -91,11 +95,14 @@ export class AttributedContent {
*/
export class AbstractAttributionManager {
/**
* @param {Array<AttributedContent<any>>} _contents
* @param {Item} _item
* @param {boolean} _forceRead read content even if it is unattributed and deleted
* @param {Array<AttributedContent<any>>} _contents - where to write the result
* @param {number} _client
* @param {number} _clock
* @param {boolean} _deleted
* @param {AbstractContent} _content
* @param {boolean} _shouldRender - whether this should render or just result in a `retain` operation
*/
readContent (_contents, _item, _forceRead) {
readContent (_contents, _client, _clock, _deleted, _content, _shouldRender) {
error.methodUnimplemented()
}
@@ -118,21 +125,23 @@ export class TwosetAttributionManager {
destroy () {}
/**
* @param {Array<AttributedContent<any>>} contents
* @param {Item} item
* @param {boolean} forceRead read content even if it is unattributed and deleted
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} client
* @param {number} clock
* @param {boolean} deleted
* @param {AbstractContent} content
* @param {boolean} shouldRender - whether this should render or just result in a `retain` operation
*/
readContent (contents, item, forceRead) {
const deleted = item.deleted
const slice = (deleted ? this.deletes : this.inserts).sliceId(item.id, item.length)
let content = slice.length === 1 ? item.content : item.content.copy()
readContent (contents, client, clock, deleted, content, shouldRender) {
const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, content.getLength())
content = slice.length === 1 ? content : content.copy()
slice.forEach(s => {
const c = content
if (s.len < c.getLength()) {
content = c.splice(s.len)
}
if (!deleted || s.attrs != null || forceRead) {
contents.push(new AttributedContent(c, deleted, s.attrs))
if (!deleted || s.attrs != null) {
contents.push(new AttributedContent(c, deleted, s.attrs, shouldRender))
}
})
}
@@ -147,13 +156,16 @@ export class NoAttributionsManager {
destroy () {}
/**
* @param {Array<AttributedContent<any>>} contents
* @param {Item} item
* @param {boolean} forceRead read content even if it is unattributed and deleted
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} _client
* @param {number} _clock
* @param {boolean} deleted
* @param {AbstractContent} content
* @param {boolean} shouldRender - whether this should render or just result in a `retain` operation
*/
readContent (contents, item, forceRead) {
if (!item.deleted || forceRead) {
contents.push(new AttributedContent(item.content, false, null))
readContent (contents, _client, _clock, deleted, content, shouldRender) {
if (!deleted || shouldRender) {
contents.push(new AttributedContent(content, deleted, null, shouldRender))
}
}
}
@@ -219,21 +231,23 @@ export class DiffAttributionManager {
}
/**
* @param {Array<AttributedContent<any>>} contents
* @param {Item} item
* @param {boolean} forceRead read content even if it is unattributed and deleted
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} client
* @param {number} clock
* @param {boolean} deleted
* @param {AbstractContent} content
* @param {boolean} shouldRender - whether this should render or just result in a `retain` operation
*/
readContent (contents, item, forceRead) {
const deleted = item.deleted || /** @type {any} */ (item.parent).doc !== this._nextDoc
const slice = (deleted ? this.deletes : this.inserts).sliceId(item.id, item.length)
let content = slice.length === 1 ? item.content : item.content.copy()
if (content instanceof ContentDeleted && slice[0].attrs != null && !this.inserts.hasId(item.id)) {
readContent (contents, client, clock, deleted, content, shouldRender) {
const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, content.getLength())
content = slice.length === 1 ? content : content.copy()
if (content instanceof ContentDeleted && slice[0].attrs != null && !this.inserts.has(client, clock)) {
// Retrieved item is never more fragmented than the newer item.
const prevItem = getItem(this._prevDocStore, item.id)
const prevItem = getItem(this._prevDocStore, createID(client, clock))
content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
// trim itemContent to the correct size.
const diffStart = prevItem.id.clock - item.id.clock
const diffEnd = prevItem.id.clock + prevItem.length - item.id.clock - item.length
const diffStart = prevItem.id.clock - clock
const diffEnd = prevItem.id.clock + prevItem.length - clock - content.getLength()
if (diffStart > 0) {
content = content.splice(diffStart)
}
@@ -246,8 +260,8 @@ export class DiffAttributionManager {
if (s.len < c.getLength()) {
content = c.splice(s.len)
}
if (!deleted || s.attrs != null || forceRead) {
contents.push(new AttributedContent(c, deleted, s.attrs))
if (!deleted || s.attrs != null || shouldRender) {
contents.push(new AttributedContent(c, deleted, s.attrs, shouldRender))
}
})
}
@@ -288,28 +302,31 @@ export class SnapshotAttributionManager {
destroy () { }
/**
* @param {Array<AttributedContent<any>>} contents
* @param {Item} item
* @param {boolean} forceRead read content even if it is unattributed and deleted
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} client
* @param {number} clock
* @param {boolean} _deleted
* @param {AbstractContent} content
* @param {boolean} shouldRender - whether this should render or just result in a `retain` operation
*/
readContent (contents, item, forceRead) {
if ((this.nextSnapshot.sv.get(item.id.client) ?? 0) <= item.id.clock) return // future item that should not be displayed
const slice = this.attrs.sliceId(item.id, item.length)
let content = slice.length === 1 ? item.content : item.content.copy()
readContent (contents, client, clock, _deleted, content, shouldRender) {
if ((this.nextSnapshot.sv.get(client) ?? 0) <= clock) return // future item that should not be displayed
const slice = this.attrs.slice(client, clock, content.getLength())
content = slice.length === 1 ? content : 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 deleted = this.nextSnapshot.ds.has(client, s.clock)
const nonExistend = (this.nextSnapshot.sv.get(client) ?? 0) <= s.clock
const c = content
if (s.len < c.getLength()) {
content = c.splice(s.len)
}
if (nonExistend) return
if (!deleted || forceRead || (s.attrs != null && s.attrs.length > 0)) {
if (!deleted || shouldRender || (s.attrs != null && s.attrs.length > 0)) {
let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null
if (s.attrs?.length === 0) {
attrsWithoutChange = null
}
contents.push(new AttributedContent(c, deleted, attrsWithoutChange))
contents.push(new AttributedContent(c, deleted, attrsWithoutChange, shouldRender))
}
})
}

View File

@@ -2267,7 +2267,7 @@ export const testAttributedContent = _tc => {
})
t.group('unformat', () => {
ytext.applyDelta([{ retain: 5, attributes: { italic: null } }])
const expectedContent = delta.createTextDelta().insert('Hell', null, { attributes: { italic: [] } }).insert('o attributions!')
const expectedContent = delta.createTextDelta().insert('Hell', { italic: null }, { attributes: { italic: [] } }).insert('o attributions!')
const attributedContent = ytext.getContent(attributionManager)
console.log(attributedContent.toJSON())
t.assert(attributedContent.equals(expectedContent))

View File

@@ -315,15 +315,16 @@ export const testElementAttributedContentViaDiffer = _tc => {
yelement.setAttribute('key', '42')
})
const attributionManager = Y.createAttributionManagerFromDiff(ydocV1, ydoc)
const expectedContent = delta.createArrayDelta().insert([delta.createTextDelta().insert('hello', null, { delete: [] })], null, { delete: [] }).insert([elem2.getContentDeep()]).insert([delta.createTextDelta().insert('world', null, { insert: [] })], null, { insert: [] })
const expectedContent = delta.createArrayDelta().insert([delta.createTextDelta().insert('hello')], null, { delete: [] }).insert([elem2.getContentDeep()]).insert([delta.createTextDelta().insert('world', null, { insert: [] })], null, { insert: [] })
const attributedContent = yelement.getContentDeep(attributionManager)
console.log('children', attributedContent.children.toJSON())
console.log('attributes', attributedContent.attributes)
t.compare(attributedContent.children.toJSON(), expectedContent.toJSON())
t.assert(attributedContent.children.equals(expectedContent))
t.compare(attributedContent.attributes, { key: { prevValue: undefined, value: '42', attribution: { insert: [] } } })
t.group('test getContentDeep', () => {
const expectedContent = delta.createArrayDelta().insert(
[delta.createTextDelta().insert('hello', null, { delete: [] })],
[delta.createTextDelta().insert('hello')],
null,
{ delete: [] }
).insert([{ nodeName: 'span', children: delta.createArrayDelta(), attributes: {} }])
@@ -344,7 +345,7 @@ export const testElementAttributedContentViaDiffer = _tc => {
t.group('test getContentDeep after some more updates', () => {
t.info('expecting diffingAttributionManager to auto update itself')
const expectedContent = delta.createArrayDelta().insert(
[delta.createTextDelta().insert('hello', null, { delete: [] })],
[delta.createTextDelta().insert('hello')],
null,
{ delete: [] }
).insert([{ nodeName: 'span', children: delta.createArrayDelta(), attributes: {} }])