diff --git a/src/index.js b/src/index.js index 6aea1c52..67d63c3d 100644 --- a/src/index.js +++ b/src/index.js @@ -108,7 +108,7 @@ export { createInsertionSetFromStructStore, diffIdMap, diffIdSet, - Attribution, + AttributionItem as Attribution, encodeIdMap } from './internals.js' diff --git a/src/types/YMap.js b/src/types/YMap.js index 22b94afb..369d9408 100644 --- a/src/types/YMap.js +++ b/src/types/YMap.js @@ -14,11 +14,18 @@ import { callTypeObservers, transact, warnPrematureAccess, - UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line + UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item, // eslint-disable-line + createAttributionFromAttrs } from '../internals.js' +import * as array from 'lib0/array' import * as iterator from 'lib0/iterator' +/** + * @template MapType + * @typedef {{ [key: string]: { prevValue: MapType | undefined, value: MapType | undefined, attribution: any } }} MapAttributedContent + */ + /** * @template T * @extends YEvent> @@ -186,6 +193,61 @@ export class YMap 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 {import('../internals.js').AbstractAttributionManager} am + * @return {MapAttributedContent} The Delta representation of this type. + * + * @public + */ + getContent (am) { + /** + * @type {MapAttributedContent} + */ + const mapcontent = {} + this._map.forEach((item, key) => { + /** + * @type {Array>} + */ + const cs = [] + am.readContent(cs, item) + const { deleted, attrs, content } = cs[cs.length - 1] + const c = array.last(content.getContent()) + const attribution = createAttributionFromAttrs(attrs, deleted) + if (deleted) { + mapcontent[key] = { prevValue: c, value: undefined, attribution } + } else { + /** + * @type {Array>} + */ + let cs = [] + for (let prevItem = item.left; prevItem != null; prevItem = prevItem.left) { + /** + * @type {Array>} + */ + const tmpcs = [] + am.readContent(tmpcs, prevItem) + cs = tmpcs.concat(cs) + if (cs[0].attrs == null) { + cs.splice(0, cs.findIndex(c => c.attrs != null)) + break + } + if (cs.length > 0) { + cs.length = 1 + } + } + const prevValue = cs.length > 0 ? array.last(cs[0].content.getContent()) : undefined + mapcontent[key] = { prevValue, value: c, attribution } + } + }) + return mapcontent + } + /** * Returns an Iterator of [key, value] pairs * diff --git a/src/types/YText.js b/src/types/YText.js index 7bd386d1..1f86b500 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -26,7 +26,8 @@ import { updateMarkerChanges, ContentType, warnPrematureAccess, - noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line + noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction, // eslint-disable-line + createAttributionFromAttrs } from '../internals.js' import * as delta from '../utils/Delta.js' @@ -1017,67 +1018,39 @@ export class YText extends AbstractType { } for (let i = 0; i < cs.length; i++) { const { content, deleted, attrs } = cs[i] - /** - * @type {import('../utils/Delta.js').Attribution?} - */ - let attributions = null - if (attrs != null) { - attributions = {} - if (deleted) { - attributions.delete = [] - } else { - attributions.insert = [] - } - attrs.forEach(attr => { - switch (attr.name) { - case 'insert': - case 'delete': - case 'suggest': { - const as = /** @type {import('../utils/Delta.js').Attribution} */ (attributions) - const ls = as[attr.name] = as[attr.name] ?? [] - ls.push(attr.val) - break - } - default: { - if (attr.name[0] !== '_') { - /** @type {any} */ (attributions)[attr.name] = attr.val - } - } - } - }) - } + const attribution = createAttributionFromAttrs(attrs, deleted) switch (content.constructor) { case ContentString: { - d.insert(/** @type {ContentString} */ (content).str, null, attributions) + d.insert(/** @type {ContentString} */ (content).str, null, attribution) break } case ContentType: case ContentEmbed: { - d.insert(/** @type {ContentEmbed | ContentType} */ (content).getContent()[0], null, attributions) + d.insert(/** @type {ContentEmbed | ContentType} */ (content).getContent()[0], null, attribution) break } case ContentFormat: { const contentFormat = /** @type {ContentFormat} */ (content) - if (attributions != null) { + if (attribution != null) { /** * @type {import('../utils/Delta.js').Attribution} */ - const formattingAttributions = object.assign({}, d.usedAttribution) - const attributesChanged = /** @type {{ [key: string]: Array }} */ (formattingAttributions.attributes = object.assign({}, formattingAttributions.attributes ?? {})) + const formattingAttribution = object.assign({}, d.usedAttribution) + const attributesChanged = /** @type {{ [key: string]: Array }} */ (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 ? attributions.delete : attributions.insert) ?? [])) - const attributedAt = (deleted ? attributions.deletedAt : attributions.insertedAt) - if (attributedAt) formattingAttributions.attributedAt = attributedAt + 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 ? attributions.deletedAt : attributions.insertedAt) - if (attributedAt != null) formattingAttributions.attributedAt = attributedAt - d.useAttribution(formattingAttributions) + const attributedAt = (deleted ? attribution.deletedAt : attribution.insertedAt) + if (attributedAt != null) formattingAttribution.attributedAt = attributedAt + d.useAttribution(formattingAttribution) } } if (!deleted) { diff --git a/src/utils/AttributionManager.js b/src/utils/AttributionManager.js index 95bfad77..7ae17eb5 100644 --- a/src/utils/AttributionManager.js +++ b/src/utils/AttributionManager.js @@ -4,6 +4,56 @@ import { import * as error from 'lib0/error' +/** + * @typedef {Object} Attribution + * @property {Array} [Attribution.insert] + * @property {number} [Attribution.insertedAt] + * @property {Array} [Attribution.suggest] + * @property {number} [Attribution.suggestedAt] + * @property {Array} [Attribution.delete] + * @property {number} [Attribution.deletedAt] + * @property {{ [key: string]: Array }} [Attribution.attributes] + * @property {number} [Attribution.attributedAt] + */ + +/** + * @param {Array>?} attrs + * @param {boolean} deleted - whether the attributed item is deleted + * @return {Attribution?} + */ +export const createAttributionFromAttrs = (attrs, deleted) => { + /** + * @type {Attribution?} + */ + let attribution = null + if (attrs != null) { + attribution = {} + if (deleted) { + attribution.delete = [] + } else { + attribution.insert = [] + } + attrs.forEach(attr => { + switch (attr.name) { + case 'insert': + case 'delete': + case 'suggest': { + const as = /** @type {import('../utils/Delta.js').Attribution} */ (attribution) + const ls = as[attr.name] = as[attr.name] ?? [] + ls.push(attr.val) + break + } + default: { + if (attr.name[0] !== '_') { + /** @type {any} */ (attribution)[attr.name] = attr.val + } + } + } + }) + } + return attribution +} + /** * @template T */ @@ -11,7 +61,7 @@ export class AttributedContent { /** * @param {AbstractContent} content * @param {boolean} deleted - * @param {Array> | null} attrs + * @param {Array> | null} attrs */ constructor (content, deleted, attrs) { this.content = content diff --git a/src/utils/Delta.js b/src/utils/Delta.js index 0bd70115..668fe3b6 100644 --- a/src/utils/Delta.js +++ b/src/utils/Delta.js @@ -6,19 +6,11 @@ import * as fun from 'lib0/function' */ /** - * @typedef {{ [key: string]: any }} FormattingAttributes + * @typedef {import('./AttributionManager.js').Attribution} Attribution */ /** - * @typedef {Object} Attribution - * @property {Array} [Attribution.insert] - * @property {number} [Attribution.insertedAt] - * @property {Array} [Attribution.suggest] - * @property {number} [Attribution.suggestedAt] - * @property {Array} [Attribution.delete] - * @property {number} [Attribution.deletedAt] - * @property {{ [key: string]: Array }} [Attribution.attributes] - * @property {number} [Attribution.attributedAt] + * @typedef {{ [key: string]: any }} FormattingAttributes */ export class InsertOp { diff --git a/src/utils/IdMap.js b/src/utils/IdMap.js index d08e6794..335657c0 100644 --- a/src/utils/IdMap.js +++ b/src/utils/IdMap.js @@ -1,6 +1,7 @@ import { _diffSet, findIndexInIdRanges, + findRangeStartInIdRanges, DSDecoderV1, DSDecoderV2, IdSetEncoderV1, IdSetEncoderV2, IdSet, ID // eslint-disable-line } from '../internals.js' @@ -14,7 +15,7 @@ import * as rabin from 'lib0/hash/rabin' /** * @template V */ -export class Attribution { +export class AttributionItem { /** * @param {string} name * @param {V} val @@ -33,7 +34,7 @@ export class Attribution { } /** - * @param {Attribution} attr + * @param {AttributionItem} attr */ const _hashAttribution = attr => { const encoder = encoding.createEncoder() @@ -46,9 +47,9 @@ const _hashAttribution = attr => { * @template V * @param {string} name * @param {V} val - * @return {Attribution} + * @return {AttributionItem} */ -export const createAttribution = (name, val) => new Attribution(name, val) +export const createAttribution = (name, val) => new AttributionItem(name, val) /** * @template T @@ -79,7 +80,7 @@ export class AttrRange { /** * @param {number} clock * @param {number} len - * @param {Array>} attrs + * @param {Array>} attrs */ constructor (clock, len, attrs) { /** @@ -107,7 +108,7 @@ export class AttrRange { /** * @template Attrs - * @typedef {{ clock: number, len: number, attrs: Array>? }} MaybeAttrRange + * @typedef {{ clock: number, len: number, attrs: Array>? }} MaybeAttrRange */ /** @@ -115,7 +116,7 @@ export class AttrRange { * * @param {number} clock * @param {number} len - * @param {Array>?} attrs + * @param {Array>?} attrs * @return {MaybeAttrRange} */ export const createMaybeAttrRange = (clock, len, attrs) => new AttrRange(clock, len, /** @type {any} */ (attrs)) @@ -140,7 +141,7 @@ export class AttrRanges { /** * @param {number} clock * @param {number} length - * @param {Array>} attrs + * @param {Array>} attrs */ add (clock, length, attrs) { if (length === 0) return @@ -241,7 +242,7 @@ export const mergeIdMaps = ams => { /** * Maps attribution to the attribution of the merged idmap. * - * @type {Map,Attribution>} + * @type {Map,AttributionItem>} */ const attrMapper = new Map() const merged = createIdMap() @@ -271,7 +272,7 @@ export const mergeIdMaps = ams => { /** * @param {IdSet} idset - * @param {Array>} attrs + * @param {Array>} attrs */ export const createIdMapFromIdSet = (idset, attrs) => { const idmap = createIdMap() @@ -279,7 +280,7 @@ export const createIdMapFromIdSet = (idset, attrs) => { attrs = _ensureAttrs(idmap, attrs) // filter out duplicates /** - * @type {Array>} + * @type {Array>} */ const checkedAttrs = [] attrs.forEach(attr => { @@ -305,11 +306,11 @@ export class IdMap { */ this.clients = new Map() /** - * @type {Map>} + * @type {Map>} */ this.attrsH = new Map() /** - * @type {Set>} + * @type {Set>} */ this.attrs = new Set() } @@ -344,7 +345,7 @@ export class IdMap { * @type {Array>} */ const ranges = dr.getIds() - let index = findIndexInIdRanges(ranges, id.clock) + let index = findRangeStartInIdRanges(ranges, id.clock) if (index !== null) { let prev = null while (index < ranges.length) { @@ -356,9 +357,9 @@ export class IdMap { r = new AttrRange(r.clock, id.clock + len - r.clock, r.attrs) } if (r.len <= 0) break - const prevEnd = prev != null ? prev.clock + prev.len : index - if (prevEnd < index) { - res.push(createMaybeAttrRange(prevEnd, index - prevEnd, null)) + const prevEnd = prev != null ? prev.clock + prev.len : id.clock + if (prevEnd < r.clock) { + res.push(createMaybeAttrRange(prevEnd, r.clock - prevEnd, null)) } prev = r res.push(r) @@ -382,7 +383,7 @@ export class IdMap { * @param {number} client * @param {number} clock * @param {number} len - * @param {Array>} attrs + * @param {Array>} attrs */ add (client, clock, len, attrs) { if (len === 0) return @@ -411,7 +412,7 @@ export const writeIdMap = (encoder, idmap) => { encoding.writeVarUint(encoder.restEncoder, idmap.clients.size) let lastWrittenClientId = 0 /** - * @type {Map, number>} + * @type {Map, number>} */ const visitedAttributions = map.create() /** @@ -482,7 +483,7 @@ export const readIdMap = decoder => { const idmap = new IdMap() const numClients = decoding.readVarUint(decoder.restDecoder) /** - * @type {Array>} + * @type {Array>} */ const visitedAttributions = [] /** @@ -503,7 +504,7 @@ export const readIdMap = decoder => { const rangeClock = decoder.readDsClock() const rangeLen = decoder.readDsLen() /** - * @type {Array>} + * @type {Array>} */ const attrs = [] const attrsLen = decoding.readVarUint(decoder.restDecoder) @@ -515,7 +516,7 @@ export const readIdMap = decoder => { if (attrNameId >= visitedAttrNames.length) { visitedAttrNames.push(decoding.readVarString(decoder.restDecoder)) } - visitedAttributions.push(new Attribution(visitedAttrNames[attrNameId], decoding.readAny(decoder.restDecoder))) + visitedAttributions.push(new AttributionItem(visitedAttrNames[attrNameId], decoding.readAny(decoder.restDecoder))) } attrs.push(visitedAttributions[attrId]) } @@ -539,8 +540,8 @@ export const decodeIdMap = data => readIdMap(new DSDecoderV2(decoding.createDeco /** * @template Attrs * @param {IdMap} idmap - * @param {Array>} attrs - * @return {Array>} + * @param {Array>} attrs + * @return {Array>} */ const _ensureAttrs = (idmap, attrs) => attrs.map(attr => idmap.attrs.has(attr) diff --git a/src/utils/IdSet.js b/src/utils/IdSet.js index 6c1b805e..96f27a97 100644 --- a/src/utils/IdSet.js +++ b/src/utils/IdSet.js @@ -168,6 +168,35 @@ export const findIndexInIdRanges = (dis, clock) => { return null } +/** + * Find the first range that contains clock or comes after clock. + * + * @param {Array} dis + * @param {number} clock + * @return {number|null} + * + * @private + * @function + */ +export const findRangeStartInIdRanges = (dis, clock) => { + let left = 0 + let right = dis.length - 1 + while (left <= right) { + const midindex = math.floor((left + right) / 2) + const mid = dis[midindex] + const midclock = mid.clock + if (midclock <= clock) { + if (clock < midclock + mid.len) { + return midindex + } + left = midindex + 1 + } else { + right = midindex - 1 + } + } + return left < dis.length ? left : null +} + /** * @param {Array} idSets * @return {IdSet} A fresh IdSet diff --git a/tests/y-map.tests.js b/tests/y-map.tests.js index 23c3f3da..fbd64cd4 100644 --- a/tests/y-map.tests.js +++ b/tests/y-map.tests.js @@ -1,7 +1,11 @@ import * as Y from '../src/index.js' import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line +import * as delta from '../src/utils/Delta.js' import { - compareIDs + compareIDs, + noAttributionsManager, + TwosetAttributionManager, + createIdMapFromIdSet } from '../src/internals.js' import * as t from 'lib0/testing' import * as prng from 'lib0/prng' @@ -613,6 +617,41 @@ export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc compare(users) } +/** + * @param {t.TestCase} _tc + */ +export const testAttributedContent = _tc => { + const ydoc = new Y.Doc({ gc: false }) + const ymap = ydoc.getMap() + let attributionManager = noAttributionsManager + + ydoc.on('afterTransaction', tr => { + // attributionManager = new TwosetAttributionManager(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertedAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) + attributionManager = new TwosetAttributionManager(createIdMapFromIdSet(tr.insertSet, []), createIdMapFromIdSet(tr.deleteSet, [])) + }) + t.group('initial value', () => { + ymap.set('test', 42) + let expectedContent = { test: { prevValue: undefined, value: 42, attribution: { insert: [] } } } + let attributedContent = ymap.getContent(attributionManager) + console.log(attributedContent) + t.compare(expectedContent, attributedContent) + }) + t.group('overwrite value', () => { + ymap.set('test', 'fourtytwo') + let expectedContent = { test: { prevValue: 42, value: 'fourtytwo', attribution: { insert: [] } } } + let attributedContent = ymap.getContent(attributionManager) + console.log(attributedContent) + t.compare(expectedContent, attributedContent) + }) + t.group('delete value', () => { + ymap.delete('test') + let expectedContent = { test: { prevValue: 'fourtytwo', value: undefined, attribution: { delete: [] } } } + let attributedContent = ymap.getContent(attributionManager) + console.log(attributedContent) + t.compare(expectedContent, attributedContent) + }) +} + /** * @type {Array} */ diff --git a/tests/y-text.tests.js b/tests/y-text.tests.js index 53da8631..ebde95f5 100644 --- a/tests/y-text.tests.js +++ b/tests/y-text.tests.js @@ -2632,7 +2632,6 @@ export const testAttributionManagerDefaultPerformance = tc => { t.info(`number of changes: ${N/1000}k`) t.info(`length of text: ${ytext.length}`) const M = 100 - t.measureTime(`original toString perf `, () => { for (let i = 0; i < M; i++) { ytext.toDelta()