From 4d582748c19bb349dc05ab7725fb8da894c6eb6e Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Sun, 18 May 2025 22:56:14 +0200 Subject: [PATCH] more generic gedDelta implementation (could be used for events) --- src/types/AbstractType.js | 20 ++-- src/types/YText.js | 191 +++++++++++++++++++++++++++++++- src/utils/AttributionManager.js | 109 ++++++++++-------- tests/y-text.tests.js | 2 +- tests/y-xml.tests.js | 7 +- 5 files changed, 268 insertions(+), 61 deletions(-) diff --git a/src/types/AbstractType.js b/src/types/AbstractType.js index c7512a0d..c1856d34 100644 --- a/src/types/AbstractType.js +++ b/src/types/AbstractType.js @@ -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>} */ 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>} */ 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)) diff --git a/src/types/YText.js b/src/types/YText.js index d2740e1b..16c37e2a 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -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 }} */ (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} @@ -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} The Delta representation of this type. + * + * @public + */ + getDelta (am = noAttributionsManager, itemsToRender = null, retainOnly = false) { + /** + * @type {import('../utils/Delta.js').TextDelta} + */ + 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>} + */ + 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 }} */ (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. * diff --git a/src/utils/AttributionManager.js b/src/utils/AttributionManager.js index 3326e594..b266f26d 100644 --- a/src/utils/AttributionManager.js +++ b/src/utils/AttributionManager.js @@ -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>?} 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> | 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>} _contents - * @param {Item} _item - * @param {boolean} _forceRead read content even if it is unattributed and deleted + * @param {Array>} _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>} contents - * @param {Item} item - * @param {boolean} forceRead read content even if it is unattributed and deleted + * @param {Array>} 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>} contents - * @param {Item} item - * @param {boolean} forceRead read content even if it is unattributed and deleted + * @param {Array>} 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>} contents - * @param {Item} item - * @param {boolean} forceRead read content even if it is unattributed and deleted + * @param {Array>} 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>} contents - * @param {Item} item - * @param {boolean} forceRead read content even if it is unattributed and deleted + * @param {Array>} 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)) } }) } diff --git a/tests/y-text.tests.js b/tests/y-text.tests.js index b0607279..27f5fca2 100644 --- a/tests/y-text.tests.js +++ b/tests/y-text.tests.js @@ -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)) diff --git a/tests/y-xml.tests.js b/tests/y-xml.tests.js index a0c0611c..44a4eb84 100644 --- a/tests/y-xml.tests.js +++ b/tests/y-xml.tests.js @@ -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: {} }])