diff --git a/src/types/YText.js b/src/types/YText.js index 2fbb1d8c..ace4c6c5 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -28,6 +28,7 @@ import { createAttributionFromAttributionItems, mergeIdSets, diffIdSet, + createIdSet, ContentDeleted } from '../internals.js' @@ -230,7 +231,7 @@ const insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes // check if we really need to remove attributes while ( currPos.right !== null && ( - currPos.right.deleted === true || ( + (currPos.right.deleted && (currPos.am == noAttributionsManager || currPos.am.contentLength(currPos.right) === 0)) || ( currPos.right.content.constructor === ContentFormat && equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (currPos.right.content).key), /** @type {ContentFormat} */ (currPos.right.content).value) ) @@ -281,7 +282,7 @@ const minimizeAttributeChanges = (currPos, attributes) => { while (true) { if (currPos.right === null) { break - } else if (currPos.right.deleted || (currPos.right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (currPos.right.content)).key] ?? null, /** @type {ContentFormat} */ (currPos.right.content).value))) { + } else if (currPos.right.deleted ? (currPos.am.contentLength(currPos.right) === 0) : (!currPos.right.deleted && currPos.right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (currPos.right.content)).key] ?? null, /** @type {ContentFormat} */ (currPos.right.content).value))) { // } else { break @@ -539,7 +540,7 @@ const deleteText = (transaction, currPos, length) => { const startAttrs = map.copy(currPos.currentAttributes) const start = currPos.right while (length > 0 && currPos.right !== null) { - if (currPos.right.deleted === false) { + if (!currPos.right.deleted) { switch (currPos.right.content.constructor) { case ContentType: case ContentEmbed: @@ -551,6 +552,19 @@ const deleteText = (transaction, currPos, length) => { currPos.right.delete(transaction) break } + } else if (currPos.am !== noAttributionsManager) { + const item = currPos.right + let d = currPos.am.contentLength(item) + if (d > 0) { + if (length < d) { + getItemCleanStart(transaction, createID(currPos.right.id.client, currPos.right.id.clock + length)) + d = length + } + // deleting already deleted content. store that information in a meta property, but do + // nothing + map.setIfUndefined(transaction.meta, 'attributedDeletes', createIdSet).add(item.id.client, item.id.clock, d) + length -= d + } } currPos.forward() } @@ -954,7 +968,7 @@ export class YText extends AbstractType { d.usedAttributes = currentAttributes usingCurrentAttributes = true if (c.deleted ? retainDeletes : retainInserts) { - d.retain(/** @type {ContentString} */ (c.content).str.length, null, attribution ?? null) + d.retain(/** @type {ContentString} */ (c.content).str.length, null, attribution ?? {}) } else { d.insert(/** @type {ContentString} */ (c.content).str, null, attribution) } diff --git a/src/utils/AttributionManager.js b/src/utils/AttributionManager.js index 573ea133..ad78a6fe 100644 --- a/src/utils/AttributionManager.js +++ b/src/utils/AttributionManager.js @@ -14,11 +14,15 @@ import { createID, mergeIdSets, IdSet, Item, Snapshot, Doc, AbstractContent, IdMap, // eslint-disable-line - intersectSets + applyUpdate, + writeIdSet, + UpdateEncoderV1, + transact } from '../internals.js' import * as error from 'lib0/error' import { ObservableV2 } from 'lib0/observable' +import * as encoding from 'lib0/encoding' /** * @todo rename this to `insertBy`, `insertAt`, .. @@ -48,35 +52,33 @@ export const createAttributionFromAttributionItems = (attrs, deleted) => { * @type {Attribution} */ const attribution = {} - if (attrs != null) { - if (deleted) { - attribution.delete = [] - } else { - attribution.insert = [] - } - attrs.forEach(attr => { - switch (attr.name) { - case 'acceptDelete': - delete attribution.delete - // eslint-disable-next-line no-fallthrough - case 'acceptInsert': - delete attribution.insert - // eslint-disable-next-line no-fallthrough - case 'insert': - case 'delete': { - 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 - } + if (deleted) { + attribution.delete = [] + } else { + attribution.insert = [] + } + attrs.forEach(attr => { + switch (attr.name) { + case 'acceptDelete': + delete attribution.delete + // eslint-disable-next-line no-fallthrough + case 'acceptInsert': + delete attribution.insert + // eslint-disable-next-line no-fallthrough + case 'insert': + case 'delete': { + 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 } @@ -123,6 +125,8 @@ export class AbstractAttributionManager extends ObservableV2 { * Calculate the length of the attributed content. This is used by iterators that walk through the * content. * + * If the content is not countable, it should return 0. + * * @param {Item} _item * @return {number} */ @@ -174,7 +178,9 @@ export class TwosetAttributionManager extends ObservableV2 { * @return {number} */ contentLength (item) { - if (!item.deleted) { + if (!item.content.isCountable()) { + return 0 + } else if (!item.deleted) { return item.length } else { return this.deletes.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0) @@ -209,7 +215,7 @@ export class NoAttributionsManager extends ObservableV2 { * @return {number} */ contentLength (item) { - return item.deleted ? 0 : item.length + return (item.deleted || !item.content.isCountable()) ? 0 : item.length } } @@ -249,7 +255,14 @@ export class DiffAttributionManager extends ObservableV2 { this._prevBOH = prevDoc.on('beforeObserverCalls', tr => { insertIntoIdSet(_prevDocInserts, tr.insertSet) insertIntoIdSet(prevDocDeletes, tr.deleteSet) - insertIntoIdMap(this.inserts, createIdMapFromIdSet(intersectSets(tr.insertSet, this.inserts), [createAttributionItem('acceptInsert', 'unknown')])) + // insertIntoIdMap(this.inserts, createIdMapFromIdSet(intersectSets(tr.insertSet, this.inserts), [createAttributionItem('acceptInsert', 'unknown')])) + if (tr.insertSet.clients.size < 2) { + tr.insertSet.forEach((attrRange, client) => { + this.inserts.delete(client, attrRange.clock, attrRange.len) + }) + } else { + this.inserts = diffIdMap(this.inserts, tr.insertSet) + } // insertIntoIdMap(this.deletes, createIdMapFromIdSet(intersectSets(tr.deleteSet, this.deletes), [createAttributionItem('acceptDelete', 'unknown')])) if (tr.deleteSet.clients.size < 2) { tr.deleteSet.forEach((attrRange, client) => { @@ -261,6 +274,41 @@ export class DiffAttributionManager extends ObservableV2 { // @todo fire update ranges on `tr.insertSet` and `tr.deleteSet` this.emit('change', [mergeIdSets([tr.insertSet, tr.deleteSet]), tr.origin, tr.local]) }) + // changes from prevDoc should always flow into suggestionDoc + // changes from suggestionDoc only flow into ydoc if suggestion-mode is disabled + this._prevUpdateListener = prevDoc.on('update', (update, origin) => { + origin !== this && applyUpdate(nextDoc, update) + }) + this._ndUpdateListener = nextDoc.on('update', (update, origin, _doc, tr) => { + // only if event is local and suggestion mode is enabled + if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === origin))) { + applyUpdate(prevDoc, update, this) + } + }) + this._afterTrListener = nextDoc.on('afterTransaction', (tr) => { + // apply deletes on attributed deletes (content that is already deleted, but is rendered by + // the attribution manager) + if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === origin))) { + const attributedDeletes = tr.meta.get('attributedDeletes') + if (attributedDeletes != null) { + transact(prevDoc, () => { + // apply attributed deletes if there are any + const ds = new UpdateEncoderV1() + encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs + writeIdSet(ds, attributedDeletes) + applyUpdate(prevDoc, ds.toUint8Array()) + }, this) + } + } + }) + this.suggestionMode = true + /** + * Optionally limit origins that may sync changes to the main doc if suggestion-mode is + * disabled. + * + * @type {Array?} + */ + this.suggestionOrigins = null this._destroyHandler = nextDoc.on('destroy', this.destroy.bind(this)) prevDoc.on('destroy', this._destroyHandler) } @@ -271,6 +319,9 @@ export class DiffAttributionManager extends ObservableV2 { this._prevDoc.off('destroy', this._destroyHandler) this._nextDoc.off('beforeObserverCalls', this._nextBOH) this._prevDoc.off('beforeObserverCalls', this._prevBOH) + this._prevDoc.off('update', this._prevUpdateListener) + this._nextDoc.off('update', this._ndUpdateListener) + this._nextDoc.off('afterTransaction', this._afterTrListener) } /** @@ -316,10 +367,18 @@ export class DiffAttributionManager extends ObservableV2 { */ contentLength (item) { if (!item.deleted) { - return item.length - } else { - return this.deletes.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0) + return item.content.isCountable() ? item.length : 0 } + const slice = this.deletes.sliceId(item.id, item.length) + let content = item.content + if (content instanceof ContentDeleted && slice[0].attrs != null && !this.inserts.hasId(item.id)) { + const prevItem = getItem(this._prevDocStore, item.id) + content = prevItem.content + } + if (!content.isCountable()) { + return 0 + } + return slice.reduce((len, s) => s.attrs != null ? len + s.len : len, 0) } } @@ -393,7 +452,12 @@ export class SnapshotAttributionManager extends ObservableV2 { * @return {number} */ contentLength (item) { - return this.attrs.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0) + return item.content.isCountable() + ? (item.deleted + ? this.attrs.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0) + : item.length + ) + : 0 } } diff --git a/src/utils/Delta.js b/src/utils/Delta.js index 362a6aaa..5352bac1 100644 --- a/src/utils/Delta.js +++ b/src/utils/Delta.js @@ -268,10 +268,7 @@ export class AbstractDelta { * @param {T | null} a * @param {T | null} b */ -const mergeAttrs = (a, b) => { - const merged = object.isEmpty(a) ? b : (object.isEmpty(b) ? a : object.assign({}, a, b)) - return object.isEmpty(merged) ? null : merged -} +const mergeAttrs = (a, b) => object.isEmpty(a) ? b : (object.isEmpty(b) ? a : object.assign({}, a, b)) /** * @template {'array' | 'text' | 'custom'} Type diff --git a/src/utils/RelativePosition.js b/src/utils/RelativePosition.js index 4f6ced71..3dd43ccd 100644 --- a/src/utils/RelativePosition.js +++ b/src/utils/RelativePosition.js @@ -9,7 +9,8 @@ import { ContentType, followRedone, getItem, - StructStore, ID, Doc, AbstractType, // eslint-disable-line + StructStore, ID, Doc, AbstractType, + noAttributionsManager, // eslint-disable-line } from '../internals.js' import * as encoding from 'lib0/encoding' @@ -72,6 +73,7 @@ export class RelativePosition { * @type {number} */ this.assoc = assoc + this.item && console.log('created relpos', this.item) // @todo remove } } @@ -156,11 +158,12 @@ export const createRelativePosition = (type, item, assoc) => { * @param {AbstractType} type The base type (e.g. YText or YArray). * @param {number} index The absolute position. * @param {number} [assoc] + * @param {import('../utils/AttributionManager.js').AbstractAttributionManager} attributionManager * @return {RelativePosition} * * @function */ -export const createRelativePositionFromTypeIndex = (type, index, assoc = 0) => { +export const createRelativePositionFromTypeIndex = (type, index, assoc = 0, attributionManager = noAttributionsManager) => { let t = type._start if (assoc < 0) { // associated to the left character or the beginning of a type, increment index if possible. @@ -170,13 +173,12 @@ export const createRelativePositionFromTypeIndex = (type, index, assoc = 0) => { index-- } while (t !== null) { - if (!t.deleted && t.countable) { - if (t.length > index) { - // case 1: found position somewhere in the linked list - return createRelativePosition(type, createID(t.id.client, t.id.clock + index), assoc) - } - index -= t.length + const len = attributionManager.contentLength(t) + if (len > index) { + // case 1: found position somewhere in the linked list + return createRelativePosition(type, createID(t.id.client, t.id.clock + index), assoc) } + index -= len if (t.right === null && assoc < 0) { // left-associated position, return last available id return createRelativePosition(type, t.lastId, assoc) @@ -282,11 +284,12 @@ const getItemWithOffset = (store, id) => { * @param {RelativePosition} rpos * @param {Doc} doc * @param {boolean} followUndoneDeletions - whether to follow undone deletions - see https://github.com/yjs/yjs/issues/638 + * @param {import('../utils/AttributionManager.js').AbstractAttributionManager} attributionManager * @return {AbsolutePosition|null} * * @function */ -export const createAbsolutePositionFromRelativePosition = (rpos, doc, followUndoneDeletions = true) => { +export const createAbsolutePositionFromRelativePosition = (rpos, doc, followUndoneDeletions = true, attributionManager = noAttributionsManager) => { const store = doc.store const rightID = rpos.item const typeID = rpos.type @@ -305,12 +308,10 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc, followUndo } type = /** @type {AbstractType} */ (right.parent) if (type._item === null || !type._item.deleted) { - index = (right.deleted || !right.countable) ? 0 : (res.diff + (assoc >= 0 ? 0 : 1)) // adjust position based on left association if necessary + index = attributionManager.contentLength(right) === 0 ? 0 : (res.diff + (assoc >= 0 ? 0 : 1)) // adjust position based on left association if necessary let n = right.left while (n !== null) { - if (!n.deleted && n.countable) { - index += n.length - } + index += attributionManager.contentLength(n) n = n.left } } diff --git a/tests/attribution.tests.js b/tests/attribution.tests.js index c6bbeea2..71ee0c1c 100644 --- a/tests/attribution.tests.js +++ b/tests/attribution.tests.js @@ -9,6 +9,24 @@ import * as Y from '../src/index.js' import * as t from 'lib0/testing' import * as delta from '../src/utils/Delta.js' +/** + * @param {t.TestCase} _tc + */ +export const testRelativePositions = _tc => { + const ydoc = new Y.Doc() + const ytext = ydoc.getText() + ytext.insert(0, 'hello world') + const v1 = Y.cloneDoc(ydoc) + ytext.delete(1, 6) + ytext.insert(1, 'x', ) + const am = Y.createAttributionManagerFromDiff(v1, ydoc) + const rel = Y.createRelativePositionFromTypeIndex(ytext, 9, 1, am) // pos after "hello wo" + const abs1 = Y.createAbsolutePositionFromRelativePosition(rel, ydoc, true, am) + const abs2 = Y.createAbsolutePositionFromRelativePosition(rel, ydoc, true) + t.assert(abs1?.index === 9) + t.assert(abs2?.index === 3) +} + /** * @param {t.TestCase} _tc */