getContent on Y.Map

This commit is contained in:
Kevin Jahns
2025-04-28 02:42:06 +02:00
parent ece7466123
commit b3171c535f
9 changed files with 225 additions and 80 deletions

View File

@@ -108,7 +108,7 @@ export {
createInsertionSetFromStructStore,
diffIdMap,
diffIdSet,
Attribution,
AttributionItem as Attribution,
encodeIdMap
} from './internals.js'

View File

@@ -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<YMap<T>>
@@ -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<MapType>} The Delta representation of this type.
*
* @public
*/
getContent (am) {
/**
* @type {MapAttributedContent<MapType>}
*/
const mapcontent = {}
this._map.forEach((item, key) => {
/**
* @type {Array<import('../internals.js').AttributedContent<any>>}
*/
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<import('../internals.js').AttributedContent<any>>}
*/
let cs = []
for (let prevItem = item.left; prevItem != null; prevItem = prevItem.left) {
/**
* @type {Array<import('../internals.js').AttributedContent<any>>}
*/
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
*

View File

@@ -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<any> }} */ (formattingAttributions.attributes = object.assign({}, formattingAttributions.attributes ?? {}))
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 ? 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) {

View File

@@ -4,6 +4,56 @@ import {
import * as error from 'lib0/error'
/**
* @typedef {Object} Attribution
* @property {Array<any>} [Attribution.insert]
* @property {number} [Attribution.insertedAt]
* @property {Array<any>} [Attribution.suggest]
* @property {number} [Attribution.suggestedAt]
* @property {Array<any>} [Attribution.delete]
* @property {number} [Attribution.deletedAt]
* @property {{ [key: string]: Array<any> }} [Attribution.attributes]
* @property {number} [Attribution.attributedAt]
*/
/**
* @param {Array<import('./IdMap.js').AttributionItem<any>>?} 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<import('./IdMap.js').Attribution<T>> | null} attrs
* @param {Array<import('./IdMap.js').AttributionItem<T>> | null} attrs
*/
constructor (content, deleted, attrs) {
this.content = content

View File

@@ -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<any>} [Attribution.insert]
* @property {number} [Attribution.insertedAt]
* @property {Array<any>} [Attribution.suggest]
* @property {number} [Attribution.suggestedAt]
* @property {Array<any>} [Attribution.delete]
* @property {number} [Attribution.deletedAt]
* @property {{ [key: string]: Array<any> }} [Attribution.attributes]
* @property {number} [Attribution.attributedAt]
* @typedef {{ [key: string]: any }} FormattingAttributes
*/
export class InsertOp {

View File

@@ -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<any>} attr
* @param {AttributionItem<any>} 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<V>}
* @return {AttributionItem<V>}
*/
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<Attribution<Attrs>>} attrs
* @param {Array<AttributionItem<Attrs>>} attrs
*/
constructor (clock, len, attrs) {
/**
@@ -107,7 +108,7 @@ export class AttrRange {
/**
* @template Attrs
* @typedef {{ clock: number, len: number, attrs: Array<Attribution<Attrs>>? }} MaybeAttrRange
* @typedef {{ clock: number, len: number, attrs: Array<AttributionItem<Attrs>>? }} MaybeAttrRange
*/
/**
@@ -115,7 +116,7 @@ export class AttrRange {
*
* @param {number} clock
* @param {number} len
* @param {Array<Attribution<Attrs>>?} attrs
* @param {Array<AttributionItem<Attrs>>?} attrs
* @return {MaybeAttrRange<Attrs>}
*/
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<Attribution<Attrs>>} attrs
* @param {Array<AttributionItem<Attrs>>} 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<any>,Attribution<any>>}
* @type {Map<AttributionItem<any>,AttributionItem<any>>}
*/
const attrMapper = new Map()
const merged = createIdMap()
@@ -271,7 +272,7 @@ export const mergeIdMaps = ams => {
/**
* @param {IdSet} idset
* @param {Array<Attribution<any>>} attrs
* @param {Array<AttributionItem<any>>} 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<Attribution<any>>}
* @type {Array<AttributionItem<any>>}
*/
const checkedAttrs = []
attrs.forEach(attr => {
@@ -305,11 +306,11 @@ export class IdMap {
*/
this.clients = new Map()
/**
* @type {Map<string, Attribution<Attrs>>}
* @type {Map<string, AttributionItem<Attrs>>}
*/
this.attrsH = new Map()
/**
* @type {Set<Attribution<Attrs>>}
* @type {Set<AttributionItem<Attrs>>}
*/
this.attrs = new Set()
}
@@ -344,7 +345,7 @@ export class IdMap {
* @type {Array<AttrRange<Attrs>>}
*/
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<Attribution<Attrs>>} attrs
* @param {Array<AttributionItem<Attrs>>} 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<Attribution<Attr>, number>}
* @type {Map<AttributionItem<Attr>, 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<Attribution<any>>}
* @type {Array<AttributionItem<any>>}
*/
const visitedAttributions = []
/**
@@ -503,7 +504,7 @@ export const readIdMap = decoder => {
const rangeClock = decoder.readDsClock()
const rangeLen = decoder.readDsLen()
/**
* @type {Array<Attribution<any>>}
* @type {Array<AttributionItem<any>>}
*/
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<Attrs>} idmap
* @param {Array<Attribution<Attrs>>} attrs
* @return {Array<Attribution<Attrs>>}
* @param {Array<AttributionItem<Attrs>>} attrs
* @return {Array<AttributionItem<Attrs>>}
*/
const _ensureAttrs = (idmap, attrs) => attrs.map(attr =>
idmap.attrs.has(attr)

View File

@@ -170,6 +170,35 @@ export const findIndexInIdRanges = (dis, clock) => {
return null
}
/**
* Find the first range that contains clock or comes after clock.
*
* @param {Array<IdRange>} 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<IdSet>} idSets
* @return {IdSet} A fresh IdSet

View File

@@ -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<function(Doc,prng.PRNG):void>}
*/

View File

@@ -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 <executed ${M} times>`, () => {
for (let i = 0; i < M; i++) {
ytext.toDelta()