implement base AttributionManager class and use in Y.Text

This commit is contained in:
Kevin Jahns
2025-04-20 19:28:36 +02:00
parent 71524a0222
commit c7ab7a4ee5
7 changed files with 176 additions and 81 deletions

31
package-lock.json generated
View File

@@ -13,14 +13,14 @@
"y-protocols": "^1.0.5"
},
"devDependencies": {
"@types/node": "^18.15.5",
"@types/node": "^22.14.1",
"concurrently": "^3.6.1",
"jsdoc": "^3.6.7",
"markdownlint-cli": "^0.41.0",
"rollup": "^4.37.0",
"standard": "^16.0.4",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^4.9.5",
"typescript": "^5.8.3",
"yjs": "."
},
"engines": {
@@ -557,12 +557,13 @@
"dev": true
},
"node_modules/@types/node": {
"version": "18.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.15.tgz",
"integrity": "sha512-AMZ2UWx+woHNfM11PyAEQmfSxi05jm9OlkxczuHeEqmvwPkYj6MWv44gbzDPefYOLysTOFyI3ziiy2ONmUZfpA==",
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
"undici-types": "~6.21.0"
}
},
"node_modules/acorn": {
@@ -4547,16 +4548,17 @@
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
@@ -4587,10 +4589,11 @@
"dev": true
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",

View File

@@ -90,14 +90,14 @@
"y-protocols": "^1.0.5"
},
"devDependencies": {
"@types/node": "^18.15.5",
"@types/node": "^22.14.1",
"concurrently": "^3.6.1",
"jsdoc": "^3.6.7",
"markdownlint-cli": "^0.41.0",
"rollup": "^4.37.0",
"standard": "^16.0.4",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^4.9.5",
"typescript": "^5.8.3",
"yjs": "."
},
"engines": {

View File

@@ -41,3 +41,4 @@ export * from './structs/ContentType.js'
export * from './structs/Item.js'
export * from './structs/Skip.js'
export * from './utils/IdMap.js'
export * from './utils/AttributionManager.js'

View File

@@ -26,8 +26,7 @@ import {
updateMarkerChanges,
ContentType,
warnPrematureAccess,
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction, IdMap, // eslint-disable-line
snapshot
noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
} from '../internals.js'
import * as delta from '../utils/Delta.js'
@@ -999,85 +998,63 @@ export class YText extends AbstractType {
* Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the
* attribution `{ isDeleted: true, .. }`.
*
* @param {IdMap<any>} [idMap]
* @param {Doc} [prevYdoc]
* @param {AbstractAttributionManager} am
* @return {import('../utils/Delta.js').Delta} The Delta representation of this type.
*
* @public
*/
getContent (idMap, prevYdoc) {
getContent (am = noAttributionsManager) {
this.doc ?? warnPrematureAccess()
const prevSnapshot = prevYdoc ? snapshot(prevYdoc) : null
const d = delta.create()
/**
* @type {{ [key: string]: any }}
*/
const currentAttributes = {}
const doc = /** @type {Doc} */ (this.doc)
const computeContent = () => {
let n = this._start
while (n !== null) {
switch (n.content.constructor) {
case ContentString: {
const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.type !== 'removed') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
for (let item = this._start; item !== null; item = item.right) {
const cs = am.getContent(item)
for (let i = 0; i < cs.length; i++) {
const { content, deleted, attrs } = cs[i]
/**
* @type {{ [key: string]: any }?}
*/
let attributions = null
if (attrs != null) {
attributions = {}
attrs.forEach(attr => {
switch (attr.name) {
case '_insertedBy':
case '_deletedBy':
case '_suggestedBy': {
const as = /** @type {any} */ (attributions)
const ls = as[attr.name] = as[attr.name] ?? []
ls.push(attr.val)
break
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.type !== 'added') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
default: {
if (attr.name[0] !== '_') {
/** @type {any} */ (attributions)[attr.name] = attr.val
}
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
str += /** @type {ContentString} */ (n.content).str
})
}
switch (content.constructor) {
case ContentString: {
d.insert(/** @type {ContentString} */ (content).str, {}, attributions)
break
}
case ContentType:
case ContentEmbed: {
packStr()
/**
* @type {Object<string,any>}
*/
const op = {
insert: n.content.getContent()[0]
}
if (currentAttributes.size > 0) {
const attrs = /** @type {Object<string,any>} */ ({})
op.attributes = attrs
currentAttributes.forEach((value, key) => {
attrs[key] = value
})
}
ops.push(op)
d.insert(/** @type {ContentEmbed | ContentType} */ (content).getContent()[0], {}, attributions)
break
}
case ContentFormat:
if (isVisible(n, snapshot)) {
packStr()
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
if (attributions != null) {
attributions.formattedBy = (deleted ? attributions.deletedBy : attributions.insertedBy) ?? []
delete attributions.deletedBy
delete attributions.insertedBy
d.useAttribution(attributions)
}
break
}
n = n.right
}
}
if (prevSnapshot) {
// snapshots are merged again after the transaction, so we need to keep the
// transaction alive until we are done
transact(doc, transaction => {
if (prevSnapshot) {
splitSnapshotAffectedStructs(transaction, prevSnapshot)
}
computeContent()
}, 'cleanup')
} else {
computeContent()
}
return d.done()
}

View File

@@ -0,0 +1,88 @@
import {
Item, AbstractContent, IdMap // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
/**
* @template T
*/
export class AttributedContent {
/**
* @param {AbstractContent} content
* @param {boolean} deleted
* @param {Array<import('./IdMap.js').Attribution<T>> | null} attrs
*/
constructor (content, deleted, attrs) {
this.content = content
this.deleted = deleted
this.attrs = attrs
}
}
/**
* Abstract class for associating Attributions to content / changes
*/
export class AbstractAttributionManager {
/**
* @param {Item} _item
* @return {Array<AttributedContent<any>>}
*/
getContent (_item) {
error.methodUnimplemented()
}
}
/**
* Abstract class for associating Attributions to content / changes
*
* @implements AbstractAttributionManager
*/
export class TwosetAttributionManager {
/**
* @param {IdMap<any>} inserts
* @param {IdMap<any>} deletes
*/
constructor (inserts, deletes) {
this.inserts = inserts
this.deletes = deletes
}
/**
* @param {Item} item
* @return {Array<AttributedContent<any>>}
*/
getContent (item) {
const deleted = item.deleted
const slice = (deleted ? this.deletes : this.inserts).slice(item.id, item.length)
let content = slice.length === 1 ? item.content : item.content.copy()
let res = slice.map(s => {
const c = content
if (s.len < c.getLength()) {
content = c.splice(s.len)
}
return new AttributedContent(c, deleted, s.attrs)
})
if (deleted) {
res = res.filter(s => s.attrs != null)
}
return res
}
}
/**
* Abstract class for associating Attributions to content / changes
*
* @implements AbstractAttributionManager
*/
export class NoAttributionsManager {
/**
* @param {Item} item
* @return {Array<AttributedContent<any>>}
*/
getContent (item) {
return item.deleted ? [] : [new AttributedContent(item.content, item.deleted, null)]
}
}
export const noAttributionsManager = new NoAttributionsManager()

View File

@@ -105,6 +105,11 @@ export class AttrRange {
}
}
/**
* @template Attrs
* @typedef {{ clock: number, len: number, attrs: Array<Attribution<Attrs>>? }} MaybeAttrRange
*/
/**
* @template Attrs
*/
@@ -287,12 +292,18 @@ export class IdMap {
}
/**
* Return attributions for a slice of ids.
*
* @param {ID} id
* @param {number} len
* @return {Array<AttrRange<Attrs>>?}
* @return {Array<MaybeAttrRange<Attrs>>}
*/
slice (id, len) {
const dr = this.clients.get(id.client)
/**
* @type {Array<MaybeAttrRange<Attrs>>}
*/
const res = []
if (dr) {
/**
* @type {Array<AttrRange<Attrs>>}
@@ -300,7 +311,7 @@ export class IdMap {
const ranges = dr.getIds()
let index = findIndexInIdRanges(ranges, id.clock)
if (index !== null) {
const res = []
let prev = null
while (index < ranges.length) {
let r = ranges[index]
if (r.clock < id.clock) {
@@ -310,13 +321,26 @@ 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(/** @type {MaybeAttrRange<Attrs>} */ (new AttrRange(prevEnd, index - prevEnd, /** @type {any} */ (null))))
}
prev = r
res.push(r)
index++
}
return res
}
}
return null
if (res.length > 0) {
const last = res[res.length - 1]
const end = last.clock + last.len
if (end < id.clock + len) {
res.push(new AttrRange(end, id.clock + len - end, []))
}
} else {
res.push(new AttrRange(id.clock, len, []))
}
return res
}
/**

View File

@@ -95,7 +95,9 @@ export const testRepeatMergingMultipleIdMaps = tc => {
const mergedAttrs = merged.slice(new ID(iclient, iclock), 1)
if (mergedAttrs) {
mergedAttrs.forEach(a => {
composed.add(iclient, a.clock, a.len, a.attrs)
if (a.attrs != null) {
composed.add(iclient, a.clock, a.len, a.attrs)
}
})
}
}