mirror of
https://github.com/yjs/yjs.git
synced 2026-01-09 07:48:01 -05:00
first tests on attributed events
This commit is contained in:
@@ -115,7 +115,8 @@ export {
|
|||||||
noAttributionsManager,
|
noAttributionsManager,
|
||||||
iterateStructsByIdSet,
|
iterateStructsByIdSet,
|
||||||
createAttributionManagerFromDiff,
|
createAttributionManagerFromDiff,
|
||||||
createIdSet
|
createIdSet,
|
||||||
|
cloneDoc
|
||||||
} from './internals.js'
|
} from './internals.js'
|
||||||
|
|
||||||
const glo = /** @type {any} */ (typeof globalThis !== 'undefined'
|
const glo = /** @type {any} */ (typeof globalThis !== 'undefined'
|
||||||
|
|||||||
@@ -1158,42 +1158,47 @@ export class YText extends AbstractType {
|
|||||||
if (ir !== rslice.length - 1) {
|
if (ir !== rslice.length - 1) {
|
||||||
itemContent.splice(idrange.len)
|
itemContent.splice(idrange.len)
|
||||||
}
|
}
|
||||||
am.readContent(cs, item.id.client, idrange.clock, item.deleted, content, idrange.exists)
|
am.readContent(cs, item.id.client, idrange.clock, item.deleted, content, idrange.exists ? 2 : 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (; item !== null && cs.length < 50; item = item.right) {
|
for (; item !== null && cs.length < 50; item = item.right) {
|
||||||
am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, !item.deleted)
|
am.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let i = 0; i < cs.length; i++) {
|
for (let i = 0; i < cs.length; i++) {
|
||||||
const c = cs[i]
|
const c = cs[i]
|
||||||
const renderDelete = c.deleted && (c.attrs != null || c.render)
|
// render (attributed) content even if it was deleted
|
||||||
const renderInsert = !c.deleted && (c.render || c.attrs != null)
|
const renderContent = c.render && (!c.deleted || c.attrs != null)
|
||||||
const attribution = (renderDelete || renderInsert) ? createAttributionFromAttributionItems(c.attrs, c.deleted).attribution : null
|
// content that was just deleted. It is not rendered as an insertion, because it doesn't
|
||||||
|
// have any attributes.
|
||||||
|
const renderDelete = c.render && c.deleted
|
||||||
|
// existing content that should be retained, only adding changed attributes
|
||||||
|
const retainContent = !c.render && (!c.deleted || c.attrs != null)
|
||||||
|
const attribution = renderContent ? createAttributionFromAttributionItems(c.attrs, c.deleted).attribution : null
|
||||||
switch (c.content.constructor) {
|
switch (c.content.constructor) {
|
||||||
case ContentType:
|
case ContentType:
|
||||||
case ContentEmbed:
|
case ContentEmbed:
|
||||||
if (renderInsert) {
|
if (renderContent) {
|
||||||
d.usedAttributes = currentAttributes
|
d.usedAttributes = currentAttributes
|
||||||
usingCurrentAttributes = true
|
usingCurrentAttributes = true
|
||||||
d.insert(c.content.getContent()[0], null, attribution)
|
d.insert(c.content.getContent()[0], null, attribution)
|
||||||
} else if (renderDelete) {
|
} else if (renderDelete) {
|
||||||
d.delete(1)
|
d.delete(1)
|
||||||
} else if (!c.deleted) {
|
} else if (retainContent) {
|
||||||
d.usedAttributes = changedAttributes
|
d.usedAttributes = changedAttributes
|
||||||
usingChangedAttributes = true
|
usingChangedAttributes = true
|
||||||
d.retain(1)
|
d.retain(1)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case ContentString:
|
case ContentString:
|
||||||
if (renderInsert || (renderDelete && attribution?.delete != null)) {
|
if (renderContent) {
|
||||||
d.usedAttributes = currentAttributes
|
d.usedAttributes = currentAttributes
|
||||||
usingCurrentAttributes = true
|
usingCurrentAttributes = true
|
||||||
d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
|
d.insert(/** @type {ContentString} */ (c.content).str, null, attribution)
|
||||||
} else if (renderDelete) {
|
} else if (renderDelete) {
|
||||||
d.delete(c.content.getLength())
|
d.delete(c.content.getLength())
|
||||||
} else if (!c.deleted) {
|
} else if (retainContent) {
|
||||||
d.usedAttributes = changedAttributes
|
d.usedAttributes = changedAttributes
|
||||||
usingChangedAttributes = true
|
usingChangedAttributes = true
|
||||||
d.retain(c.content.getLength())
|
d.retain(c.content.getLength())
|
||||||
@@ -1204,7 +1209,7 @@ export class YText extends AbstractType {
|
|||||||
const currAttrVal = currentAttributes[key] ?? null
|
const currAttrVal = currentAttributes[key] ?? null
|
||||||
// @todo write a function "updateCurrentAttributes" and "updateChangedAttributes"
|
// @todo write a function "updateCurrentAttributes" and "updateChangedAttributes"
|
||||||
// # Update Attributes
|
// # Update Attributes
|
||||||
if (renderDelete || renderInsert) {
|
if (renderContent || renderDelete) {
|
||||||
// create fresh references
|
// create fresh references
|
||||||
if (usingCurrentAttributes) {
|
if (usingCurrentAttributes) {
|
||||||
currentAttributes = object.assign({}, currentAttributes)
|
currentAttributes = object.assign({}, currentAttributes)
|
||||||
@@ -1215,30 +1220,34 @@ export class YText extends AbstractType {
|
|||||||
changedAttributes = object.assign({}, changedAttributes)
|
changedAttributes = object.assign({}, changedAttributes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (renderInsert) {
|
if (renderContent || renderDelete) {
|
||||||
if (equalAttrs(value, currAttrVal)) {
|
if (c.deleted) {
|
||||||
// item.delete(transaction)
|
// content was deleted, but is possibly attributed
|
||||||
} else if (equalAttrs(value, previousAttributes[key] ?? null)) {
|
if (equalAttrs(value, currAttrVal)) {
|
||||||
delete changedAttributes[key]
|
// nop
|
||||||
} else {
|
} else if (equalAttrs(currAttrVal, previousAttributes[key] ?? null) && changedAttributes[key] !== undefined) {
|
||||||
changedAttributes[key] = value
|
delete changedAttributes[key]
|
||||||
|
} else {
|
||||||
|
changedAttributes[key] = currAttrVal
|
||||||
|
}
|
||||||
|
// current attributes doesn't change
|
||||||
|
previousAttributes[key] = value
|
||||||
|
} else { // !c.deleted
|
||||||
|
// content was inserted, and is possibly attributed
|
||||||
|
if (equalAttrs(value, currAttrVal)) {
|
||||||
|
// item.delete(transaction)
|
||||||
|
} else if (equalAttrs(value, previousAttributes[key] ?? null)) {
|
||||||
|
delete changedAttributes[key]
|
||||||
|
} else {
|
||||||
|
changedAttributes[key] = value
|
||||||
|
}
|
||||||
|
if (value == null) {
|
||||||
|
delete currentAttributes[key]
|
||||||
|
} else {
|
||||||
|
currentAttributes[key] = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (value == null) {
|
} else if (retainContent && !c.deleted) {
|
||||||
delete currentAttributes[key]
|
|
||||||
} else {
|
|
||||||
currentAttributes[key] = value
|
|
||||||
}
|
|
||||||
} else if (renderDelete) {
|
|
||||||
if (equalAttrs(value, currAttrVal)) {
|
|
||||||
// nop
|
|
||||||
} else if (equalAttrs(currAttrVal, previousAttributes[key] ?? null) && changedAttributes[key] !== undefined) {
|
|
||||||
delete changedAttributes[key]
|
|
||||||
} else {
|
|
||||||
changedAttributes[key] = currAttrVal
|
|
||||||
}
|
|
||||||
// current attributes doesn't change
|
|
||||||
previousAttributes[key] = value
|
|
||||||
} else if (!c.deleted) {
|
|
||||||
// fresh reference to currentAttributes only
|
// fresh reference to currentAttributes only
|
||||||
if (usingCurrentAttributes) {
|
if (usingCurrentAttributes) {
|
||||||
currentAttributes = object.assign({}, currentAttributes)
|
currentAttributes = object.assign({}, currentAttributes)
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ export class AttributedContent {
|
|||||||
* @param {AbstractContent} content
|
* @param {AbstractContent} content
|
||||||
* @param {boolean} deleted
|
* @param {boolean} deleted
|
||||||
* @param {Array<import('./IdMap.js').AttributionItem<T>> | null} attrs
|
* @param {Array<import('./IdMap.js').AttributionItem<T>> | null} attrs
|
||||||
* @param {boolean} render
|
* @param {0|1|2} renderBehavior
|
||||||
*/
|
*/
|
||||||
constructor (content, deleted, attrs, render) {
|
constructor (content, deleted, attrs, renderBehavior) {
|
||||||
this.content = content
|
this.content = content
|
||||||
this.deleted = deleted
|
this.deleted = deleted
|
||||||
this.attrs = attrs
|
this.attrs = attrs
|
||||||
this.render = render
|
this.render = renderBehavior === 0 ? false : (renderBehavior === 1 ? (!deleted || attrs != null) : true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export class AbstractAttributionManager {
|
|||||||
* @param {number} _clock
|
* @param {number} _clock
|
||||||
* @param {boolean} _deleted
|
* @param {boolean} _deleted
|
||||||
* @param {AbstractContent} _content
|
* @param {AbstractContent} _content
|
||||||
* @param {boolean} _shouldRender - whether this should render or just result in a `retain` operation
|
* @param {0|1|2} _shouldRender - 0: if undeleted or attributed, render as a retain operation. 1: render only if undeleted or attributed. 2: render as insert operation (if unattributed and deleted, render as delete).
|
||||||
*/
|
*/
|
||||||
readContent (_contents, _client, _clock, _deleted, _content, _shouldRender) {
|
readContent (_contents, _client, _clock, _deleted, _content, _shouldRender) {
|
||||||
error.methodUnimplemented()
|
error.methodUnimplemented()
|
||||||
@@ -130,7 +130,7 @@ export class TwosetAttributionManager {
|
|||||||
* @param {number} clock
|
* @param {number} clock
|
||||||
* @param {boolean} deleted
|
* @param {boolean} deleted
|
||||||
* @param {AbstractContent} content
|
* @param {AbstractContent} content
|
||||||
* @param {boolean} shouldRender - whether this should render or just result in a `retain` operation
|
* @param {0|1|2} shouldRender - whether this should render or just result in a `retain` operation
|
||||||
*/
|
*/
|
||||||
readContent (contents, client, clock, deleted, content, shouldRender) {
|
readContent (contents, client, clock, deleted, content, shouldRender) {
|
||||||
const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, content.getLength())
|
const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, content.getLength())
|
||||||
@@ -140,7 +140,7 @@ export class TwosetAttributionManager {
|
|||||||
if (s.len < c.getLength()) {
|
if (s.len < c.getLength()) {
|
||||||
content = c.splice(s.len)
|
content = c.splice(s.len)
|
||||||
}
|
}
|
||||||
if (!deleted || s.attrs != null) {
|
if (!deleted || s.attrs != null || shouldRender) {
|
||||||
contents.push(new AttributedContent(c, deleted, s.attrs, shouldRender))
|
contents.push(new AttributedContent(c, deleted, s.attrs, shouldRender))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -161,7 +161,7 @@ export class NoAttributionsManager {
|
|||||||
* @param {number} _clock
|
* @param {number} _clock
|
||||||
* @param {boolean} deleted
|
* @param {boolean} deleted
|
||||||
* @param {AbstractContent} content
|
* @param {AbstractContent} content
|
||||||
* @param {boolean} shouldRender - whether this should render or just result in a `retain` operation
|
* @param {0|1|2} shouldRender - whether this should render or just result in a `retain` operation
|
||||||
*/
|
*/
|
||||||
readContent (contents, _client, _clock, deleted, content, shouldRender) {
|
readContent (contents, _client, _clock, deleted, content, shouldRender) {
|
||||||
if (!deleted || shouldRender) {
|
if (!deleted || shouldRender) {
|
||||||
@@ -236,7 +236,7 @@ export class DiffAttributionManager {
|
|||||||
* @param {number} clock
|
* @param {number} clock
|
||||||
* @param {boolean} deleted
|
* @param {boolean} deleted
|
||||||
* @param {AbstractContent} content
|
* @param {AbstractContent} content
|
||||||
* @param {boolean} shouldRender - whether this should render or just result in a `retain` operation
|
* @param {0|1|2} shouldRender - whether this should render or just result in a `retain` operation
|
||||||
*/
|
*/
|
||||||
readContent (contents, client, clock, deleted, content, shouldRender) {
|
readContent (contents, client, clock, deleted, content, shouldRender) {
|
||||||
const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, content.getLength())
|
const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, content.getLength())
|
||||||
@@ -244,10 +244,11 @@ export class DiffAttributionManager {
|
|||||||
if (content instanceof ContentDeleted && slice[0].attrs != null && !this.inserts.has(client, clock)) {
|
if (content instanceof ContentDeleted && slice[0].attrs != null && !this.inserts.has(client, clock)) {
|
||||||
// Retrieved item is never more fragmented than the newer item.
|
// Retrieved item is never more fragmented than the newer item.
|
||||||
const prevItem = getItem(this._prevDocStore, createID(client, clock))
|
const prevItem = getItem(this._prevDocStore, createID(client, clock))
|
||||||
|
const originalContentLen = content.getLength()
|
||||||
content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
|
content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
|
||||||
// trim itemContent to the correct size.
|
// trim itemContent to the correct size.
|
||||||
const diffStart = prevItem.id.clock - clock
|
const diffStart = clock - prevItem.id.clock
|
||||||
const diffEnd = prevItem.id.clock + prevItem.length - clock - content.getLength()
|
const diffEnd = prevItem.id.clock + prevItem.length - clock - originalContentLen
|
||||||
if (diffStart > 0) {
|
if (diffStart > 0) {
|
||||||
content = content.splice(diffStart)
|
content = content.splice(diffStart)
|
||||||
}
|
}
|
||||||
@@ -260,7 +261,7 @@ export class DiffAttributionManager {
|
|||||||
if (s.len < c.getLength()) {
|
if (s.len < c.getLength()) {
|
||||||
content = c.splice(s.len)
|
content = c.splice(s.len)
|
||||||
}
|
}
|
||||||
if (!deleted || s.attrs != null || shouldRender) {
|
if (shouldRender || !deleted || s.attrs != null) {
|
||||||
contents.push(new AttributedContent(c, deleted, s.attrs, shouldRender))
|
contents.push(new AttributedContent(c, deleted, s.attrs, shouldRender))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -307,7 +308,7 @@ export class SnapshotAttributionManager {
|
|||||||
* @param {number} clock
|
* @param {number} clock
|
||||||
* @param {boolean} _deleted
|
* @param {boolean} _deleted
|
||||||
* @param {AbstractContent} content
|
* @param {AbstractContent} content
|
||||||
* @param {boolean} shouldRender - whether this should render or just result in a `retain` operation
|
* @param {0|1|2} shouldRender - whether this should render or just result in a `retain` operation
|
||||||
*/
|
*/
|
||||||
readContent (contents, client, clock, _deleted, content, shouldRender) {
|
readContent (contents, client, clock, _deleted, content, shouldRender) {
|
||||||
if ((this.nextSnapshot.sv.get(client) ?? 0) <= clock) return // future item that should not be displayed
|
if ((this.nextSnapshot.sv.get(client) ?? 0) <= clock) return // future item that should not be displayed
|
||||||
@@ -321,12 +322,12 @@ export class SnapshotAttributionManager {
|
|||||||
content = c.splice(s.len)
|
content = c.splice(s.len)
|
||||||
}
|
}
|
||||||
if (nonExistend) return
|
if (nonExistend) return
|
||||||
if (!deleted || shouldRender || (s.attrs != null && s.attrs.length > 0)) {
|
if (shouldRender || !deleted || (s.attrs != null && s.attrs.length > 0)) {
|
||||||
let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null
|
let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null
|
||||||
if (s.attrs?.length === 0) {
|
if (s.attrs?.length === 0) {
|
||||||
attrsWithoutChange = null
|
attrsWithoutChange = null
|
||||||
}
|
}
|
||||||
contents.push(new AttributedContent(c, deleted, attrsWithoutChange, !deleted))
|
contents.push(new AttributedContent(c, deleted, attrsWithoutChange, shouldRender))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
YXmlElement,
|
YXmlElement,
|
||||||
YXmlFragment,
|
YXmlFragment,
|
||||||
transact,
|
transact,
|
||||||
ContentDoc, Item, Transaction, YEvent // eslint-disable-line
|
applyUpdate,
|
||||||
|
ContentDoc, Item, Transaction, YEvent, // eslint-disable-line
|
||||||
|
encodeStateAsUpdate
|
||||||
} from '../internals.js'
|
} from '../internals.js'
|
||||||
|
|
||||||
import { ObservableV2 } from 'lib0/observable'
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
@@ -345,3 +347,12 @@ export class Doc extends ObservableV2 {
|
|||||||
super.destroy()
|
super.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Doc} ydoc
|
||||||
|
*/
|
||||||
|
export const cloneDoc = ydoc => {
|
||||||
|
const clone = new Doc()
|
||||||
|
applyUpdate(clone, encodeStateAsUpdate(ydoc))
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|||||||
34
tests/attribution.tests.js
Normal file
34
tests/attribution.tests.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Testing if encoding/decoding compatibility and integration compatibility is given.
|
||||||
|
* We expect that the document always looks the same, even if we upgrade the integration algorithm, or add additional encoding approaches.
|
||||||
|
*
|
||||||
|
* The v1 documents were generated with Yjs v13.2.0 based on the randomisized tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 testAttributedEvents = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.getText()
|
||||||
|
ytext.insert(0, 'hello world')
|
||||||
|
const v1 = Y.cloneDoc(ydoc)
|
||||||
|
ydoc.transact(() => {
|
||||||
|
ytext.delete(6, 5)
|
||||||
|
})
|
||||||
|
let am = Y.createAttributionManagerFromDiff(v1, ydoc)
|
||||||
|
const c1 = ytext.getDelta(am)
|
||||||
|
t.compare(c1, delta.createTextDelta().insert('hello ').insert('world', null, { delete: [] }))
|
||||||
|
let calledObserver = false
|
||||||
|
ytext.observe(event => {
|
||||||
|
const d = event.getDelta(am)
|
||||||
|
t.compare(d, delta.createTextDelta().retain(11).insert('!', null, { insert: [] }))
|
||||||
|
calledObserver = true
|
||||||
|
})
|
||||||
|
ytext.insert(11, '!')
|
||||||
|
t.assert(calledObserver)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import * as relativePositions from './relativePositions.tests.js'
|
|||||||
import * as delta from './delta.tests.js'
|
import * as delta from './delta.tests.js'
|
||||||
import * as idset from './IdSet.tests.js'
|
import * as idset from './IdSet.tests.js'
|
||||||
import * as idmap from './IdMap.tests.js'
|
import * as idmap from './IdMap.tests.js'
|
||||||
|
import * as attribution from './attribution.tests.js'
|
||||||
|
|
||||||
import { runTests } from 'lib0/testing'
|
import { runTests } from 'lib0/testing'
|
||||||
import { isBrowser, isNode } from 'lib0/environment'
|
import { isBrowser, isNode } from 'lib0/environment'
|
||||||
@@ -24,7 +25,7 @@ if (isBrowser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tests = {
|
const tests = {
|
||||||
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions, delta, idset, idmap
|
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions, delta, idset, idmap, attribution
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user