diff --git a/package.json b/package.json index 09308234..f8bc994a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/index.js b/src/index.js index 10eee599..c402b968 100644 --- a/src/index.js +++ b/src/index.js @@ -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' diff --git a/src/internals.js b/src/internals.js index 09cc90ff..e93896c5 100644 --- a/src/internals.js +++ b/src/internals.js @@ -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' diff --git a/src/structs/Item.js b/src/structs/Item.js index f8f69ebf..14b3dd88 100644 --- a/src/structs/Item.js +++ b/src/structs/Item.js @@ -149,7 +149,7 @@ export const splitStruct = (transaction, leftStruct, diff) => { * @param {Array} 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. diff --git a/src/utils/AttributionManager.js b/src/utils/AttributionManager.js index da95e97f..2d31afee 100644 --- a/src/utils/AttributionManager.js +++ b/src/utils/AttributionManager.js @@ -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() }) diff --git a/src/utils/IdMap.js b/src/utils/IdMap.js index 5bd05a86..8fbb3fd8 100644 --- a/src/utils/IdMap.js +++ b/src/utils/IdMap.js @@ -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} 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} idmap + * @param {(attr: ContentAttribute) => boolean} predicate + * @return {IdMap} + */ +export const filterIdMap = (idmap, predicate) => { + const filtered = createIdMap() + idmap.clients.forEach((ranges, client) => { + /** + * @type {Array>} + */ + 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 +} diff --git a/src/utils/IdSet.js b/src/utils/IdSet.js index 329d9aab..39930cfe 100644 --- a/src/utils/IdSet.js +++ b/src/utils/IdSet.js @@ -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 diff --git a/src/utils/UndoManager.js b/src/utils/UndoManager.js index 6f243ba0..d2aa15b8 100644 --- a/src/utils/UndoManager.js +++ b/src/utils/UndoManager.js @@ -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() +} diff --git a/src/utils/meta.js b/src/utils/meta.js index 3a61a88f..28e4e954 100644 --- a/src/utils/meta.js +++ b/src/utils/meta.js @@ -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} inserts * @param {import('./IdMap.js').IdMap} deletes diff --git a/tests/attribution.tests.js b/tests/attribution.tests.js index 78eb03f4..ced24184 100644 --- a/tests/attribution.tests.js +++ b/tests/attribution.tests.js @@ -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')) }