attributions filtering, undo of contentIds

This commit is contained in:
Kevin Jahns
2026-01-10 23:53:38 +01:00
parent 1d78034ae9
commit 65bc32f1ae
10 changed files with 121 additions and 23 deletions

View File

@@ -32,7 +32,7 @@
"types": "./dist/src/internals.d.ts",
"default": "./src/internals.js"
},
"/meta": {
"./meta": {
"types": "./dist/src/utils/meta.d.ts",
"default": "./src/utils/meta.js"
},

View File

@@ -116,7 +116,13 @@ export {
decodeIdMap,
diffDocsToDelta,
getPathTo,
Attributions
Attributions,
filterIdMap,
undoContentIds,
createContentIds,
createContentMap,
createContentIdsFromContentMap,
createContentMapFromContentIds
} from './internals.js'
const glo = /** @type {any} */ (typeof globalThis !== 'undefined'

View File

@@ -18,6 +18,7 @@ export * from './utils/StructSet.js'
export * from './utils/IdMap.js'
export * from './utils/AttributionManager.js'
export * from './utils/delta-helpers.js'
export * from './utils/meta.js'
export * from './ytype.js'
export * from './structs/AbstractStruct.js'
export * from './structs/GC.js'

View File

@@ -149,7 +149,7 @@ export const splitStruct = (transaction, leftStruct, diff) => {
* @param {Array<StackItem>} stack
* @param {ID} id
*/
const isDeletedByUndoStack = (stack, id) => array.some(stack, /** @param {StackItem} s */ s => s.deletions.hasId(id))
const isDeletedByUndoStack = (stack, id) => array.some(stack, /** @param {StackItem} s */ s => s.deletes.hasId(id))
/**
* Redoes the effect of this operation.

View File

@@ -468,7 +468,7 @@ export class DiffAttributionManager extends ObservableV2 {
this._prevDoc.transact(tr => {
applyUpdate(this._prevDoc, encodeStateAsUpdate(this._nextDoc))
const um = new UndoManager(this._prevDoc)
um.undoStack.push(new StackItem(tr.deleteSet, tr.insertSet))
um.undoStack.push(new StackItem(tr.insertSet, tr.deleteSet))
um.undo()
um.destroy()
})
@@ -497,7 +497,7 @@ export class DiffAttributionManager extends ObservableV2 {
writeStructsFromIdSet(encoder, this._nextDoc.store, inserts)
writeIdSet(encoder, deletes)
const um = new UndoManager(this._nextDoc)
um.undoStack.push(new StackItem(deletes, inserts))
um.undoStack.push(new StackItem(inserts, deletes))
um.undo()
um.destroy()
})

View File

@@ -5,7 +5,9 @@ import {
_deleteRangeFromIdSet,
DSDecoderV1, DSDecoderV2, IdSetEncoderV1, IdSetEncoderV2, IdSet, ID, // eslint-disable-line
_insertIntoIdSet,
_intersectSets
_intersectSets,
createIdSet,
IdRanges
} from '../internals.js'
import * as array from 'lib0/array'
@@ -294,6 +296,22 @@ export const createIdMapFromIdSet = (idset, attrs) => {
return idmap
}
/**
* Create an IdSet from an IdMap by stripping the attributes.
*
* @param {IdMap<any>} idmap
* @return {IdSet}
*/
export const createIdSetFromIdMap = idmap => {
const idset = createIdSet()
idmap.clients.forEach((ranges, client) => {
const idRanges = new IdRanges([])
ranges.getIds().forEach(range => idRanges.add(range.clock, range.len))
idset.clients.set(client, idRanges)
})
return idset
}
/**
* @template Attrs
*/
@@ -324,6 +342,10 @@ export class IdMap {
})
}
isEmpty () {
return this.clients.size === 0
}
/**
* @param {ID} id
* @return {boolean}
@@ -616,3 +638,36 @@ export const diffIdMap = (set, exclude) => {
}
export const intersectMaps = _intersectSets
/**
* Filter attributes in an IdMap based on a predicate function.
* Returns a new IdMap containing idranges that match the predicate.
*
* @template Attrs
* @param {IdMap<Attrs>} idmap
* @param {(attr: ContentAttribute<Attrs>) => boolean} predicate
* @return {IdMap<Attrs>}
*/
export const filterIdMap = (idmap, predicate) => {
const filtered = createIdMap()
idmap.clients.forEach((ranges, client) => {
/**
* @type {Array<AttrRange<Attrs>>}
*/
const attrRanges = []
ranges.getIds().forEach((range) => {
if (range.attrs.some(predicate)) {
const rangeCpy = range.copyWith(range.clock, range.len)
attrRanges.push(rangeCpy)
rangeCpy.attrs.forEach(attr => {
filtered.attrs.add(attr)
filtered.attrsH.set(attr.hash(), attr)
})
}
})
if (attrRanges.length > 0) {
filtered.clients.set(client, new AttrRanges(attrRanges))
}
})
return filtered
}

View File

@@ -108,7 +108,7 @@ export class IdRanges {
*/
add (clock, length) {
const last = this._ids[this._ids.length - 1]
if (last.clock + last.len === clock) {
if (last != null && last.clock + last.len === clock) {
if (this._lastIsUsed) {
this._ids[this._ids.length - 1] = new IdRange(last.clock, last.len + length)
this._lastIsUsed = false

View File

@@ -8,7 +8,8 @@ import {
isParentOf,
followRedone,
getItemCleanStart,
YEvent, Transaction, Doc, Item, GC, IdSet, YType // eslint-disable-line
YEvent, Transaction, Doc, Item, GC, IdSet, YType, // eslint-disable-line
diffIdSet
} from '../internals.js'
import * as time from 'lib0/time'
@@ -18,12 +19,12 @@ import { ObservableV2 } from 'lib0/observable'
export class StackItem {
/**
* @param {IdSet} deletions
* @param {IdSet} insertions
* @param {IdSet} deletions
*/
constructor (deletions, insertions) {
this.insertions = insertions
this.deletions = deletions
constructor (insertions, deletions) {
this.inserts = insertions
this.deletes = deletions
/**
* Use this to save and restore metadata like selection range
*/
@@ -36,7 +37,7 @@ export class StackItem {
* @param {StackItem} stackItem
*/
const clearUndoManagerStackItem = (tr, um, stackItem) => {
iterateStructsByIdSet(tr, stackItem.deletions, item => {
iterateStructsByIdSet(tr, stackItem.deletes, item => {
if (item instanceof Item && um.scope.some(type => type === tr.doc || isParentOf(/** @type {YType} */ (type), item))) {
keepItem(item, false)
}
@@ -70,7 +71,7 @@ const popStackItem = (undoManager, stack, eventType) => {
*/
const itemsToDelete = []
let performedChange = false
iterateStructsByIdSet(transaction, stackItem.insertions, struct => {
iterateStructsByIdSet(transaction, stackItem.inserts, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
@@ -84,18 +85,18 @@ const popStackItem = (undoManager, stack, eventType) => {
}
}
})
iterateStructsByIdSet(transaction, stackItem.deletions, struct => {
iterateStructsByIdSet(transaction, stackItem.deletes, struct => {
if (
struct instanceof Item &&
scope.some(type => type === transaction.doc || isParentOf(/** @type {YType} */ (type), struct)) &&
// Never redo structs in stackItem.insertions because they were created and deleted in the same capture interval.
!stackItem.insertions.hasId(struct.id)
!stackItem.inserts.hasId(struct.id)
) {
itemsToRedo.add(struct)
}
})
itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.insertions, undoManager.ignoreRemoteMapChanges, undoManager) !== null || performedChange
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.inserts, undoManager.ignoreRemoteMapChanges, undoManager) !== null || performedChange
})
// We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered.
@@ -230,11 +231,11 @@ export class UndoManager extends ObservableV2 {
if (this.lastChange > 0 && now - this.lastChange < this.captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op
const lastOp = stack[stack.length - 1]
lastOp.deletions = mergeIdSets([lastOp.deletions, transaction.deleteSet])
lastOp.insertions = mergeIdSets([lastOp.insertions, insertions])
lastOp.deletes = mergeIdSets([lastOp.deletes, transaction.deleteSet])
lastOp.inserts = mergeIdSets([lastOp.inserts, insertions])
} else {
// create a new stack op
stack.push(new StackItem(transaction.deleteSet, insertions))
stack.push(new StackItem(insertions, transaction.deleteSet))
didAdd = true
}
if (!undoing && !redoing) {
@@ -389,3 +390,17 @@ export class UndoManager extends ObservableV2 {
super.destroy()
}
}
/**
* @experimental
*
* This is not guaranteed to work on documents with gc enabled!
*
* @param {Doc} ydoc
* @param {import('./meta.js').ContentIds} contentIds
*/
export const undoContentIds = (ydoc, contentIds) => {
const um = new UndoManager(ydoc)
um.undoStack.push(new StackItem(diffIdSet(contentIds.inserts, contentIds.deletes), diffIdSet(contentIds.deletes, contentIds.inserts)))
um.undo()
}

View File

@@ -20,6 +20,14 @@ import { IdSetEncoderV2 } from './UpdateEncoder.js'
*/
export const createContentIds = (inserts, deletes) => ({ inserts, deletes })
/**
* @param {ContentMap} contentMap
*/
export const createContentIdsFromContentMap = contentMap => createContentIds(
idmap.createIdSetFromIdMap(contentMap.inserts),
idmap.createIdSetFromIdMap(contentMap.deletes)
)
/**
* @param {import('./IdMap.js').IdMap<any>} inserts
* @param {import('./IdMap.js').IdMap<any>} deletes

View File

@@ -137,6 +137,7 @@ export const testChildListContent = () => {
*/
export const testAttributionSession1 = tc => {
const { testConnector, users, text0, text1 } = init(tc, { users: 3 })
users[0].gc = false
const globalAttributions = new Y.Attributions()
const v1 = Y.cloneDoc(users[0])
users.forEach(user => user.on('update', (update, _, ydoc, tr) => {
@@ -152,9 +153,21 @@ export const testAttributionSession1 = tc => {
const d1 = text0.toDelta(Y.createAttributionManagerFromDiff(v1, users[0], { attrs: globalAttributions }))
t.compare(d1, delta.create().insert('a', null, { insert: ['0'] }).insert('b', null, { insert: ['1'] }))
const v2 = Y.cloneDoc(users[0])
text0.delete(0, 1)
text1.insert(1, 'c')
text0.delete(1, 1)
text1.insert(2, 'c')
testConnector.flushAllMessages()
const d2 = text0.toDelta(Y.createAttributionManagerFromDiff(v2, users[0], { attrs: globalAttributions }))
t.compare(d2, delta.create().insert('a', null, { delete: ['0'] }).insert('c', null, { insert: ['1'] }).insert('b'))
t.compare(d2, delta.create().insert('a').insert('b', null, { delete: ['0'] }).insert('c', null, { insert: ['1'] }))
const onlyUser0ChangesAttributed = {
inserts: Y.filterIdMap(globalAttributions.inserts, attr => attr.name === 'insert' && attr.val === '0'),
deletes: Y.filterIdMap(globalAttributions.deletes, attr => attr.name === 'delete' && attr.val === '0')
}
const amUser0 = new Y.TwosetAttributionManager(onlyUser0ChangesAttributed.inserts, onlyUser0ChangesAttributed.deletes)
const d3 = text0.toDelta(amUser0)
t.compare(d3, delta.create().insert('a', null, { insert: ['0'] }).insert('b', null, { delete: ['0'] }).insert('c'))
Y.undoContentIds(users[0], Y.createContentIdsFromContentMap(onlyUser0ChangesAttributed))
const d4 = text0.toDelta()
t.compare(d4, delta.create().insert('bc'))
}