suggestion fixes

This commit is contained in:
Kevin Jahns
2025-05-29 21:47:29 +02:00
parent 90514dd51b
commit f1ae2a78a1
5 changed files with 150 additions and 56 deletions

View File

@@ -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)
}

View File

@@ -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<any>?}
*/
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
}
}

View File

@@ -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

View File

@@ -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<any>} 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<any>} */ (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
}
}

View File

@@ -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
*/