Compare commits

..

9 Commits

Author SHA1 Message Date
Kevin Jahns
6403bc2bb5 13.5.24 2022-01-11 21:55:31 +01:00
Kevin Jahns
20e1234af2 implement performant push in arrays 2022-01-11 21:53:20 +01:00
Kevin Jahns
3aebb8db83 correct typings for a test 2022-01-10 22:04:28 +01:00
Kevin Jahns
645f05b0bb 13.5.23 2021-12-15 18:48:10 +01:00
Kevin Jahns
1cf709093c export V1 ⇔ V2 update format conversion. Closes #363 2021-12-15 18:45:08 +01:00
Kevin Jahns
9569d3e297 Merge pull request #360 from MarcoPolo/patch-1
Add a new provider, y-libp2p
2021-12-12 21:37:25 +01:00
Marco Munizaga
507edccdf8 Add a new provider, y-libp2p 2021-12-10 15:29:49 -08:00
Kevin Jahns
9914f48a52 Merge pull request #359 from YousefED/patch-1
Add SyncedStore to readme
2021-12-09 11:33:22 +01:00
Yousef
d57629b36d Add SyncedStore to readme
Not sure what the preferred way to share it would be, for now I added it to the Bindings :)
2021-12-08 14:35:12 +01:00
23 changed files with 479 additions and 1358 deletions

View File

@@ -99,6 +99,7 @@ are implemented in separate modules.
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](https://github.com/yjs/y-monaco) | [demo](https://demos.yjs.dev/monaco/monaco.html) |
| [Slate](https://github.com/ianstormtaylor/slate) | ✔ | [slate-yjs](https://github.com/bitphinix/slate-yjs) | [demo](https://bitphinix.github.io/slate-yjs-example) |
| [valtio](https://github.com/pmndrs/valtio) | | [valtio-yjs](https://github.com/dai-shi/valtio-yjs) | [demo](https://codesandbox.io/s/valtio-yjs-demo-ox3iy) |
| React / Vue / MobX | | [SyncedStore](https://syncedstore.org) | [demo](https://syncedstore.org/docs/react) |
### Providers
@@ -127,6 +128,12 @@ leveldb database.
Efficiently persists document updates to the browsers indexeddb database.
The document is immediately available and only diffs need to be synced through the
network provider.
</dd>
<dt><a href="https://github.com/marcopolo/y-libp2p">y-libp2p</a></dt>
<dd>
Uses <a href="https://libp2p.io/">libp2p</a> to propagate updates via
<a href="https://github.com/libp2p/specs/tree/master/pubsub/gossipsub">GossipSub</a>.
Also includes a peer-sync mechanism to catch up on missed updates.
</dd>
<dt><a href="https://github.com/yjs/y-dat">y-dat</a></dt>
<dd>
@@ -712,7 +719,7 @@ Y.applyUpdate(ydoc1, diff2)
Y.applyUpdate(ydoc2, diff1)
```
### Example: Syncing clients without loading the Y.Doc
#### Example: Syncing clients without loading the Y.Doc
It is possible to sync clients and compute delta updates without loading the Yjs
document to memory. Yjs exposes an API to compute the differences directly on the
@@ -736,6 +743,17 @@ currentState1 = Y.mergeUpdates([currentState1, diff2])
currentState1 = Y.mergeUpdates([currentState1, diff1])
```
#### Using V2 update format
Yjs implements two update formats. By default you are using the V1 update format.
You can opt-in into the V2 update format wich provides much better compression.
It is not yet used by all providers. However, you can already use it if
you are building your own provider. All below functions are available with the
suffix "V2". E.g. `Y.applyUpdate``Y.applyUpdateV2`. We also support conversion
functions between both formats: `Y.convertUpdateFormatV1ToV2` & `Y.convertUpdateFormatV2ToV1`.
#### Update API
<dl>
<b><code>Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])</code></b>
<dd>
@@ -768,6 +786,14 @@ Encode the missing differences to another update message. This function works
similarly to <code>Y.encodeStateAsUpdate(ydoc, stateVector)</code> but works
on updates instead.
</dd>
<b><code>convertUpdateFormatV1ToV2</code></b>
<dd>
Convert V1 update format to the V2 update format.
</dd>
<b><code>convertUpdateFormatV2ToV1</code></b>
<dd>
Convert V2 update format to the V1 update format.
</dd>
</dl>
### Relative Positions

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.6.0-2",
"version": "13.5.24",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.6.0-2",
"version": "13.5.24",
"description": "Shared Editing Library",
"main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs",
@@ -19,7 +19,7 @@
"lint": "markdownlint README.md && standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true",
"serve-docs": "npm run docs && http-server ./docs/",
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && test -e dist/src/index.d.ts && test -e dist/yjs.cjs && test -e dist/yjs.cjs",
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repetition-time 1000 && test -e dist/src/index.d.ts && test -e dist/yjs.cjs && test -e dist/yjs.cjs",
"debug": "concurrently 'http-server -o test.html' 'npm run watch'",
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs",
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs",

View File

@@ -85,12 +85,16 @@ export {
encodeRelativePosition,
decodeRelativePosition,
diffUpdate,
diffUpdateV2
diffUpdateV2,
convertUpdateFormatV1ToV2,
convertUpdateFormatV2ToV1
} from './internals.js'
const glo = /** @type {any} */ (typeof window !== 'undefined'
? window
// @ts-ignore
: typeof global !== 'undefined' ? global : {})
const importIdentifier = '__ $YJS$ __'
if (glo[importIdentifier] === true) {

View File

@@ -8,7 +8,6 @@ export * from './utils/encoding.js'
export * from './utils/EventHandler.js'
export * from './utils/ID.js'
export * from './utils/isParentOf.js'
export * from './utils/ListIterator.js'
export * from './utils/logging.js'
export * from './utils/PermanentUserData.js'
export * from './utils/RelativePosition.js'
@@ -39,7 +38,6 @@ export * from './structs/ContentFormat.js'
export * from './structs/ContentJSON.js'
export * from './structs/ContentAny.js'
export * from './structs/ContentString.js'
export * from './structs/ContentMove.js'
export * from './structs/ContentType.js'
export * from './structs/Item.js'
export * from './structs/Skip.js'

View File

@@ -1,286 +0,0 @@
import * as error from 'lib0/error'
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import * as math from 'lib0/math'
import {
AbstractType, ContentType, RelativePosition, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore, getItem, getItemCleanStart, getItemCleanEnd // eslint-disable-line
} from '../internals.js'
import { decodeRelativePosition, encodeRelativePosition } from 'yjs'
/**
* @param {ContentMove} moved
* @param {Transaction} tr
* @return {{ start: Item, end: Item | null }} $start (inclusive) is the beginning and $end (exclusive) is the end of the moved area
*/
export const getMovedCoords = (moved, tr) => {
let start // this (inclusive) is the beginning of the moved area
let end // this (exclusive) is the first item after start that is not part of the moved area
if (moved.start.item) {
if (moved.start.assoc < 0) {
start = getItemCleanEnd(tr, moved.start.item)
start = start.right
} else {
start = getItemCleanStart(tr, moved.start.item)
}
} else if (moved.start.tname != null) {
start = tr.doc.get(moved.start.tname)._start
} else if (moved.start.type) {
start = /** @type {ContentType} */ (getItem(tr.doc.store, moved.start.type).content).type._start
} else {
error.unexpectedCase()
}
if (moved.end.item) {
if (moved.end.assoc < 0) {
end = getItemCleanEnd(tr, moved.end.item)
end = end.right
} else {
end = getItemCleanStart(tr, moved.end.item)
}
} else {
end = null
}
return { start: /** @type {Item} */ (start), end }
}
/**
* @todo remove this if not needed
*
* @param {ContentMove} moved
* @param {Item} movedItem
* @param {Transaction} tr
* @param {function(Item):void} cb
*/
export const iterateMoved = (moved, movedItem, tr, cb) => {
/**
* @type {{ start: Item | null, end: Item | null }}
*/
let { start, end } = getMovedCoords(moved, tr)
while (start !== end && start != null) {
if (!start.deleted) {
if (start.moved === movedItem) {
if (start.content.constructor === ContentMove) {
iterateMoved(start.content, start, tr, cb)
} else {
cb(start)
}
}
}
start = start.right
}
}
/**
* @param {ContentMove} moved
* @param {Item} movedItem
* @param {Set<Item>} trackedMovedItems
* @param {Transaction} tr
* @return {boolean} true if there is a loop
*/
export const findMoveLoop = (moved, movedItem, trackedMovedItems, tr) => {
if (trackedMovedItems.has(movedItem)) {
return true
}
trackedMovedItems.add(movedItem)
/**
* @type {{ start: Item | null, end: Item | null }}
*/
let { start, end } = getMovedCoords(moved, tr)
while (start !== end && start != null) {
if (start.deleted && start.moved === movedItem && start.content.constructor === ContentMove) {
if (findMoveLoop(start.content, start, trackedMovedItems, tr)) {
return true
}
}
start = start.right
}
return false
}
/**
* @private
*/
export class ContentMove {
/**
* @param {RelativePosition} start
* @param {RelativePosition} end
* @param {number} priority if we want to move content that is already moved, we need to assign a higher priority to this move operation.
*/
constructor (start, end, priority) {
this.start = start
this.end = end
this.priority = priority
/**
* We store which Items+ContentMove we override. Once we delete
* this ContentMove, we need to re-integrate the overridden items.
*
* This representation can be improved if we ever run into memory issues because of too many overrides.
* Ideally, we should probably just re-iterate the document and re-integrate all moved items.
* This is fast enough and reduces memory footprint significantly.
*
* @type {Set<Item>}
*/
this.overrides = new Set()
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [null]
}
/**
* @return {boolean}
*/
isCountable () {
return false
}
/**
* @return {ContentMove}
*/
copy () {
return new ContentMove(this.start, this.end, this.priority)
}
/**
* @param {number} offset
* @return {ContentMove}
*/
splice (offset) {
return this
}
/**
* @param {ContentMove} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
/** @type {AbstractType<any>} */ (item.parent)._searchMarker = []
/**
* @type {{ start: Item | null, end: Item | null }}
*/
let { start, end } = getMovedCoords(this, transaction)
let maxPriority = 0
// If this ContentMove was created locally, we set prio = -1. This indicates
// that we want to set prio to the current prio-maximum of the moved range.
const adaptPriority = this.priority < 0
while (start !== end && start != null) {
if (!start.deleted) {
const currMoved = start.moved
const nextPrio = currMoved ? /** @type {ContentMove} */ (currMoved.content).priority : -1
if (currMoved === null || adaptPriority || nextPrio < this.priority || currMoved.id.client < item.id.client || (currMoved.id.client === item.id.client && currMoved.id.clock < item.id.clock)) {
if (currMoved !== null) {
this.overrides.add(currMoved)
}
maxPriority = math.max(maxPriority, nextPrio)
// was already moved
if (start.moved && !transaction.prevMoved.has(start)) {
// we need to know which item previously moved an item
transaction.prevMoved.set(start, start.moved)
}
start.moved = item
} else {
/** @type {ContentMove} */ (currMoved.content).overrides.add(item)
}
}
start = start.right
}
if (adaptPriority) {
this.priority = maxPriority + 1
}
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
delete (transaction, item) {
/**
* @type {{ start: Item | null, end: Item | null }}
*/
let { start, end } = getMovedCoords(this, transaction)
while (start !== end && start != null) {
if (start.moved === item) {
start.moved = null
}
start = start.right
}
/**
* @param {Item} reIntegrateItem
*/
const reIntegrate = reIntegrateItem => {
const content = /** @type {ContentMove} */ (reIntegrateItem.content)
if (reIntegrateItem.deleted) {
// potentially we can integrate the items that reIntegrateItem overrides
content.overrides.forEach(reIntegrate)
} else {
content.integrate(transaction, reIntegrateItem)
}
}
this.overrides.forEach(reIntegrate)
}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
const isCollapsed = this.isCollapsed()
encoding.writeUint8(encoder.restEncoder, isCollapsed ? 1 : 0)
encoder.writeBuf(encodeRelativePosition(this.start))
if (!isCollapsed) {
encoder.writeBuf(encodeRelativePosition(this.end))
}
encoding.writeVarUint(encoder.restEncoder, this.priority)
}
/**
* @return {number}
*/
getRef () {
return 11
}
isCollapsed () {
return this.start.item === this.end.item && this.start.item !== null
}
}
/**
* @private
* @todo use binary encoding option for start & end relpos's
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentMove}
*/
export const readContentMove = decoder => {
const isCollapsed = decoding.readUint8(decoder.restDecoder) === 1
const start = decodeRelativePosition(decoder.readBuf())
const end = isCollapsed ? start.clone() : decodeRelativePosition(decoder.readBuf())
if (isCollapsed) {
end.assoc = -1
}
return new ContentMove(start, end, decoding.readVarUint(decoder.restDecoder))
}

View File

@@ -21,14 +21,12 @@ import {
createID,
readContentFormat,
readContentType,
readContentMove,
addChangedTypeToTransaction,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as binary from 'lib0/binary'
import { ContentMove } from './ContentMove.js'
/**
* @todo This should return several items
@@ -118,12 +116,6 @@ export const splitItem = (transaction, leftItem, diff) => {
/** @type {AbstractType<any>} */ (rightItem.parent)._map.set(rightItem.parentSub, rightItem)
}
leftItem.length = diff
if (leftItem.moved) {
const m = transaction.prevMoved.get(leftItem)
if (m) {
transaction.prevMoved.set(rightItem, m)
}
}
return rightItem
}
@@ -289,18 +281,11 @@ export class Item extends AbstractStruct {
*/
this.parentSub = parentSub
/**
* If this type's effect is reundone this type refers to the type-id that undid
* If this type's effect is reundone this type refers to the type that undid
* this operation.
*
* @type {ID | null}
*/
this.redone = null
/**
* This property is reused by the moved prop. In this case this property refers to an Item.
*
* @type {Item | null}
*/
this.moved = null
/**
* @type {AbstractContent}
*/
@@ -382,21 +367,11 @@ export class Item extends AbstractStruct {
if (this.parent && this.parent.constructor === ID && this.id.client !== this.parent.client && this.parent.clock >= getState(store, this.parent.client)) {
return this.parent.client
}
if (this.content.constructor === ContentMove) {
const c = /** @type {ContentMove} */ (this.content)
const start = c.start.item
const end = c.isCollapsed() ? null : c.end.item
if (start && start.clock >= getState(store, start.client)) {
return start.client
}
if (end && end.clock >= getState(store, end.client)) {
return end.client
}
}
// We have all missing ids, now find the items
if (this.origin) {
this.left = getItemCleanEnd(transaction, this.origin)
this.left = getItemCleanEnd(transaction, store, this.origin)
this.origin = this.left.lastId
}
if (this.rightOrigin) {
@@ -424,7 +399,6 @@ export class Item extends AbstractStruct {
this.parent = /** @type {ContentType} */ (parentItem.content).type
}
}
return null
}
@@ -435,7 +409,7 @@ export class Item extends AbstractStruct {
integrate (transaction, offset) {
if (offset > 0) {
this.id.clock += offset
this.left = getItemCleanEnd(transaction, createID(this.id.client, this.id.clock - 1))
this.left = getItemCleanEnd(transaction, transaction.doc.store, createID(this.id.client, this.id.clock - 1))
this.origin = this.left.lastId
this.content = this.content.splice(offset)
this.length -= offset
@@ -595,22 +569,21 @@ export class Item extends AbstractStruct {
this.deleted === right.deleted &&
this.redone === null &&
right.redone === null &&
this.moved === right.moved &&
this.content.constructor === right.content.constructor &&
this.content.mergeWith(right.content)
) {
if (right.marker) {
// Right will be "forgotten", so we delete all
// search markers that reference right.
const searchMarker = /** @type {AbstractType<any>} */ (this.parent)._searchMarker
if (searchMarker) {
for (let i = searchMarker.length - 1; i >= 0; i--) {
if (searchMarker[i].nextItem === right) {
// @todo do something more efficient than splicing..
searchMarker.splice(i, 1)
const searchMarker = /** @type {AbstractType<any>} */ (this.parent)._searchMarker
if (searchMarker) {
searchMarker.forEach(marker => {
if (marker.p === right) {
// right is going to be "forgotten" so we need to update the marker
marker.p = this
// adjust marker index
if (!this.deleted && this.countable) {
marker.index -= this.length
}
}
}
})
}
if (right.keep) {
this.keep = true
@@ -640,7 +613,7 @@ export class Item extends AbstractStruct {
this.markDeleted()
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
addChangedTypeToTransaction(transaction, parent, this.parentSub)
this.content.delete(transaction, this)
this.content.delete(transaction)
}
}
@@ -652,7 +625,6 @@ export class Item extends AbstractStruct {
if (!this.deleted) {
throw error.unexpectedCase()
}
this.moved = null
this.content.gc(store)
if (parentGCd) {
replaceStruct(store, this, new GC(this.id, this.length))
@@ -738,8 +710,7 @@ export const contentRefs = [
readContentType, // 7
readContentAny, // 8
readContentDoc, // 9
() => { error.unexpectedCase() }, // 10 - Skip is not ItemContent
readContentMove // 11
() => { error.unexpectedCase() } // 10 - Skip is not ItemContent
]
/**
@@ -806,9 +777,8 @@ export class AbstractContent {
/**
* @param {Transaction} transaction
* @param {Item} item
*/
delete (transaction, item) {
delete (transaction) {
throw error.methodUnimplemented()
}

View File

@@ -10,7 +10,7 @@ import {
createID,
ContentAny,
ContentBinary,
ListIterator,
getItemCleanStart,
ContentDoc, YText, YArray, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
} from '../internals.js'
@@ -21,6 +21,67 @@ import * as math from 'lib0/math'
const maxSearchMarker = 80
/**
* A unique timestamp that identifies each marker.
*
* Time is relative,.. this is more like an ever-increasing clock.
*
* @type {number}
*/
let globalSearchMarkerTimestamp = 0
export class ArraySearchMarker {
/**
* @param {Item} p
* @param {number} index
*/
constructor (p, index) {
p.marker = true
this.p = p
this.index = index
this.timestamp = globalSearchMarkerTimestamp++
}
}
/**
* @param {ArraySearchMarker} marker
*/
const refreshMarkerTimestamp = marker => { marker.timestamp = globalSearchMarkerTimestamp++ }
/**
* This is rather complex so this function is the only thing that should overwrite a marker
*
* @param {ArraySearchMarker} marker
* @param {Item} p
* @param {number} index
*/
const overwriteMarker = (marker, p, index) => {
marker.p.marker = false
marker.p = p
p.marker = true
marker.index = index
marker.timestamp = globalSearchMarkerTimestamp++
}
/**
* @param {Array<ArraySearchMarker>} searchMarker
* @param {Item} p
* @param {number} index
*/
const markPosition = (searchMarker, p, index) => {
if (searchMarker.length >= maxSearchMarker) {
// override oldest marker (we don't want to create more objects)
const marker = searchMarker.reduce((a, b) => a.timestamp < b.timestamp ? a : b)
overwriteMarker(marker, p, index)
return marker
} else {
// create new marker
const pm = new ArraySearchMarker(p, index)
searchMarker.push(pm)
return pm
}
}
/**
* Search marker help us to find positions in the associative array faster.
*
@@ -28,69 +89,82 @@ const maxSearchMarker = 80
*
* A maximum of `maxSearchMarker` objects are created.
*
* @template T
* @param {Transaction} tr
* This function always returns a refreshed marker (updated timestamp)
*
* @param {AbstractType<any>} yarray
* @param {number} index
* @param {function(ListIterator):T} f
*/
export const useSearchMarker = (tr, yarray, index, f) => {
const searchMarker = yarray._searchMarker
if (searchMarker === null || yarray._start === null || index < 5) {
return f(new ListIterator(yarray).forward(tr, index))
export const findMarker = (yarray, index) => {
if (yarray._start === null || index === 0 || yarray._searchMarker === null) {
return null
}
if (searchMarker.length === 0) {
const sm = new ListIterator(yarray).forward(tr, index)
searchMarker.push(sm)
if (sm.nextItem) sm.nextItem.marker = true
const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b)
let p = yarray._start
let pindex = 0
if (marker !== null) {
p = marker.p
pindex = marker.index
refreshMarkerTimestamp(marker) // we used it, we might need to use it again
}
const sm = searchMarker.reduce(
(a, b, arrayIndex) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b
)
const newIsCheaper = math.abs(sm.index - index) > index
const createFreshMarker = searchMarker.length < maxSearchMarker && (math.abs(sm.index - index) > 5 || newIsCheaper)
const fsm = createFreshMarker ? (newIsCheaper ? new ListIterator(yarray) : sm.clone()) : sm
const prevItem = /** @type {Item} */ (sm.nextItem)
if (createFreshMarker) {
searchMarker.push(fsm)
// iterate to right if possible
while (p.right !== null && pindex < index) {
if (!p.deleted && p.countable) {
if (index < pindex + p.length) {
break
}
pindex += p.length
}
p = p.right
}
const diff = fsm.index - index
if (diff > 0) {
fsm.backward(tr, diff)
// iterate to left if necessary (might be that pindex > index)
while (p.left !== null && pindex > index) {
p = p.left
if (!p.deleted && p.countable) {
pindex -= p.length
}
}
// we want to make sure that p can't be merged with left, because that would screw up everything
// in that cas just return what we have (it is most likely the best marker anyway)
// iterate to left until p can't be merged with left
while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) {
p = p.left
if (!p.deleted && p.countable) {
pindex -= p.length
}
}
// @todo remove!
// assure position
// {
// let start = yarray._start
// let pos = 0
// while (start !== p) {
// if (!start.deleted && start.countable) {
// pos += start.length
// }
// start = /** @type {Item} */ (start.right)
// }
// if (pos !== pindex) {
// debugger
// throw new Error('Gotcha position fail!')
// }
// }
// if (marker) {
// if (window.lengthes == null) {
// window.lengthes = []
// window.getLengthes = () => window.lengthes.sort((a, b) => a - b)
// }
// window.lengthes.push(marker.index - pindex)
// console.log('distance', marker.index - pindex, 'len', p && p.parent.length)
// }
if (marker !== null && math.abs(marker.index - pindex) < /** @type {YText|YArray<any>} */ (p.parent).length / maxSearchMarker) {
// adjust existing marker
overwriteMarker(marker, p, pindex)
return marker
} else {
fsm.forward(tr, -diff)
// create new marker
return markPosition(yarray._searchMarker, p, pindex)
}
// @todo remove this tests
/*
const otherTesting = new ListIterator(yarray)
otherTesting.forward(tr, index)
if (otherTesting.nextItem !== fsm.nextItem || otherTesting.index !== fsm.index || otherTesting.reachedEnd !== fsm.reachedEnd) {
throw new Error('udtirane')
}
*/
const result = f(fsm)
if (fsm.reachedEnd) {
fsm.reachedEnd = false
const nextItem = /** @type {Item} */ (fsm.nextItem)
if (nextItem.countable && !nextItem.deleted) {
fsm.index -= nextItem.length
}
fsm.rel = 0
}
if (!createFreshMarker) {
// reused old marker and we moved to a different position
prevItem.marker = false
}
const fsmItem = fsm.nextItem
if (fsmItem) {
if (fsmItem.marker) {
// already marked, forget current iterator
searchMarker.splice(searchMarker.findIndex(m => m === fsm), 1)
} else {
fsmItem.marker = true
}
}
return result
}
/**
@@ -98,25 +172,39 @@ export const useSearchMarker = (tr, yarray, index, f) => {
*
* This should be called before doing a deletion!
*
* @param {Array<ListIterator>} searchMarker
* @param {Array<ArraySearchMarker>} searchMarker
* @param {number} index
* @param {number} len If insertion, len is positive. If deletion, len is negative.
* @param {ListIterator|null} origSearchMarker Do not update this searchmarker because it is the one we used to manipulate. @todo !=null for improved perf in ytext
*/
export const updateMarkerChanges = (searchMarker, index, len, origSearchMarker) => {
export const updateMarkerChanges = (searchMarker, index, len) => {
for (let i = searchMarker.length - 1; i >= 0; i--) {
const marker = searchMarker[i]
if (marker !== origSearchMarker) {
if (len > 0 && index === marker.index) {
// inserting at a marked position deletes the marked position because we can't do a simple transformation
// (we don't know whether to insert directly before or directly after the position)
const m = searchMarker[i]
if (len > 0) {
/**
* @type {Item|null}
*/
let p = m.p
p.marker = false
// Ideally we just want to do a simple position comparison, but this will only work if
// search markers don't point to deleted items for formats.
// Iterate marker to prev undeleted countable position so we know what to do when updating a position
while (p && (p.deleted || !p.countable)) {
p = p.left
if (p && !p.deleted && p.countable) {
// adjust position. the loop should break now
m.index -= p.length
}
}
if (p === null || p.marker === true) {
// remove search marker if updated position is null or if position is already marked
searchMarker.splice(i, 1)
if (marker.nextItem) marker.nextItem.marker = false
continue
}
if (index < marker.index) { // a simple index <= m.index check would actually suffice
marker.index = math.max(index, marker.index + len)
}
m.p = p
p.marker = true
}
if (index < m.index || (len > 0 && index === m.index)) { // a simple index <= m.index check would actually suffice
m.index = math.max(index, m.index + len)
}
}
}
@@ -194,16 +282,9 @@ export class AbstractType {
*/
this._dEH = createEventHandler()
/**
* @type {null | Array<ListIterator>}
* @type {null | Array<ArraySearchMarker>}
*/
this._searchMarker = null
/**
* You can store custom stuff here.
* This might be useful to associate your application state to this shared type.
*
* @type {Map<any, any>}
*/
this.meta = new Map()
}
/**
@@ -513,6 +594,31 @@ export const typeListForEachSnapshot = (type, f, snapshot) => {
}
}
/**
* @param {AbstractType<any>} type
* @param {number} index
* @return {any}
*
* @private
* @function
*/
export const typeListGet = (type, index) => {
const marker = findMarker(type, index)
let n = type._start
if (marker !== null) {
n = marker.p
index -= marker.index
}
for (; n !== null; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
return n.content.getContent()[index]
}
index -= n.length
}
}
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
@@ -577,6 +683,128 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
packJsonContent()
}
const lengthExceeded = error.create('Length exceeded!')
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {number} index
* @param {Array<Object<string,any>|Array<any>|number|null|string|Uint8Array>} content
*
* @private
* @function
*/
export const typeListInsertGenerics = (transaction, parent, index, content) => {
if (index > parent._length) {
throw lengthExceeded
}
if (index === 0) {
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, index, content.length)
}
return typeListInsertGenericsAfter(transaction, parent, null, content)
}
const startIndex = index
const marker = findMarker(parent, index)
let n = parent._start
if (marker !== null) {
n = marker.p
index -= marker.index
// we need to iterate one to the left so that the algorithm works
if (index === 0) {
// @todo refactor this as it actually doesn't consider formats
n = n.prev // important! get the left undeleted item so that we can actually decrease index
index += (n && n.countable && !n.deleted) ? n.length : 0
}
}
for (; n !== null; n = n.right) {
if (!n.deleted && n.countable) {
if (index <= n.length) {
if (index < n.length) {
// insert in-between
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
break
}
index -= n.length
}
}
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, startIndex, content.length)
}
return typeListInsertGenericsAfter(transaction, parent, n, content)
}
/**
* Pushing content is special as we generally want to push after the last item. So we don't have to update
* the serach marker.
*
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Array<Object<string,any>|Array<any>|number|null|string|Uint8Array>} content
*
* @private
* @function
*/
export const typeListPushGenerics = (transaction, parent, content) => {
// Use the marker with the highest index and iterate to the right.
const marker = (parent._searchMarker || []).reduce((maxMarker, currMarker) => currMarker.index > maxMarker.index ? currMarker : maxMarker, { index: 0, p: parent._start })
let n = marker.p
if (n) {
while (n.right) {
n = n.right
}
}
return typeListInsertGenericsAfter(transaction, parent, n, content)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {number} index
* @param {number} length
*
* @private
* @function
*/
export const typeListDelete = (transaction, parent, index, length) => {
if (length === 0) { return }
const startIndex = index
const startLength = length
const marker = findMarker(parent, index)
let n = parent._start
if (marker !== null) {
n = marker.p
index -= marker.index
}
// compute the first item to be deleted
for (; n !== null && index > 0; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
index -= n.length
}
}
// delete all items until done
while (length > 0 && n !== null) {
if (!n.deleted) {
if (length < n.length) {
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length))
}
n.delete(transaction)
length -= n.length
}
n = n.right
}
if (length > 0) {
throw lengthExceeded
}
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, startIndex, -startLength + length /* in case we remove the above exception */)
}
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent

View File

@@ -5,14 +5,20 @@
import {
YEvent,
AbstractType,
typeListGet,
typeListToArray,
typeListForEach,
typeListCreateIterator,
typeListInsertGenerics,
typeListPushGenerics,
typeListDelete,
typeListMap,
YArrayRefID,
callTypeObservers,
transact,
ListIterator,
useSearchMarker,
createRelativePositionFromTypeIndex,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
} from '../internals.js'
import { typeListSlice } from './AbstractType.js'
/**
* Event that describes the changes on a YArray
@@ -44,7 +50,7 @@ export class YArray extends AbstractType {
*/
this._prelimContent = []
/**
* @type {Array<ListIterator>}
* @type {Array<ArraySearchMarker>}
*/
this._searchMarker = []
}
@@ -124,70 +130,12 @@ export class YArray extends AbstractType {
* @param {Array<T>} content The array of content
*/
insert (index, content) {
if (content.length > 0) {
if (this.doc !== null) {
transact(this.doc, transaction => {
useSearchMarker(transaction, this, index, walker =>
walker.insertArrayValue(transaction, content)
)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
}
}
}
/**
* Move a single item from $index to $target.
*
* @todo make sure that collapsed moves are removed (i.e. when moving the same item twice)
*
* @param {number} index
* @param {number} target
*/
move (index, target) {
if (index === target || index + 1 === target || index >= this.length) {
// It doesn't make sense to move a range into the same range (it's basically a no-op).
return
}
if (this.doc !== null) {
transact(this.doc, transaction => {
const left = createRelativePositionFromTypeIndex(this, index, 1)
const right = left.clone()
right.assoc = -1
useSearchMarker(transaction, this, target, walker => {
walker.insertMove(transaction, left, right)
})
typeListInsertGenerics(transaction, this, index, content)
})
} else {
const content = /** @type {Array<any>} */ (this._prelimContent).splice(index, 1)
;/** @type {Array<any>} */ (this._prelimContent).splice(target, 0, ...content)
}
}
/**
* @param {number} start Inclusive move-start
* @param {number} end Inclusive move-end
* @param {number} target
* @param {number} assocStart >=0 if start should be associated with the right character. See relative-position assoc parameter.
* @param {number} assocEnd >= 0 if end should be associated with the right character.
*/
moveRange (start, end, target, assocStart = 1, assocEnd = -1) {
if (start <= target && target <= end) {
// It doesn't make sense to move a range into the same range (it's basically a no-op).
return
}
if (this.doc !== null) {
transact(this.doc, transaction => {
const left = createRelativePositionFromTypeIndex(this, start, assocStart)
const right = createRelativePositionFromTypeIndex(this, end + 1, assocEnd)
useSearchMarker(transaction, this, target, walker => {
walker.insertMove(transaction, left, right)
})
})
} else {
const content = /** @type {Array<any>} */ (this._prelimContent).splice(start, end - start + 1)
;/** @type {Array<any>} */ (this._prelimContent).splice(target, 0, ...content)
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
}
}
@@ -195,9 +143,17 @@ export class YArray extends AbstractType {
* Appends content to this YArray.
*
* @param {Array<T>} content Array of content to append.
*
* @todo Use the following implementation in all types.
*/
push (content) {
this.insert(this.length, content)
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListPushGenerics(transaction, this, content)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).push(...content)
}
}
/**
@@ -218,9 +174,7 @@ export class YArray extends AbstractType {
delete (index, length = 1) {
if (this.doc !== null) {
transact(this.doc, transaction => {
useSearchMarker(transaction, this, index, walker =>
walker.delete(transaction, length)
)
typeListDelete(transaction, this, index, length)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, length)
@@ -234,11 +188,7 @@ export class YArray extends AbstractType {
* @return {T}
*/
get (index) {
return transact(/** @type {Doc} */ (this.doc), transaction =>
useSearchMarker(transaction, this, index, walker =>
walker.slice(transaction, 1)[0]
)
)
return typeListGet(this, index)
}
/**
@@ -247,9 +197,7 @@ export class YArray extends AbstractType {
* @return {Array<T>}
*/
toArray () {
return transact(/** @type {Doc} */ (this.doc), tr =>
new ListIterator(this).slice(tr, this.length)
)
return typeListToArray(this)
}
/**
@@ -260,11 +208,7 @@ export class YArray extends AbstractType {
* @return {Array<T>}
*/
slice (start = 0, end = this.length) {
return transact(/** @type {Doc} */ (this.doc), transaction =>
useSearchMarker(transaction, this, start, walker =>
walker.slice(transaction, end < 0 ? this.length + end - start : end - start)
)
)
return typeListSlice(this, start, end)
}
/**
@@ -286,9 +230,7 @@ export class YArray extends AbstractType {
* callback function
*/
map (f) {
return transact(/** @type {Doc} */ (this.doc), tr =>
new ListIterator(this).map(tr, f)
)
return typeListMap(this, /** @type {any} */ (f))
}
/**
@@ -297,16 +239,14 @@ export class YArray extends AbstractType {
* @param {function(T,number,YArray<T>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
return transact(/** @type {Doc} */ (this.doc), tr =>
new ListIterator(this).forEach(tr, f)
)
typeListForEach(this, f)
}
/**
* @return {IterableIterator<T>}
*/
[Symbol.iterator] () {
return this.toArray().values()
return typeListCreateIterator(this)
}
/**

View File

@@ -20,15 +20,14 @@ import {
splitSnapshotAffectedStructs,
iterateDeletedStructs,
iterateStructs,
findMarker,
typeMapDelete,
typeMapSet,
typeMapGet,
typeMapGetAll,
updateMarkerChanges,
ContentType,
useSearchMarker,
findIndexCleanStart,
ListIterator, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
} from '../internals.js'
import * as object from 'lib0/object'
@@ -126,30 +125,10 @@ const findNextPosition = (transaction, pos, count) => {
*/
const findPosition = (transaction, parent, index) => {
const currentAttributes = new Map()
if (parent._searchMarker) {
return useSearchMarker(transaction, parent, index, listIter => {
let left, right
if (listIter.rel > 0) {
// must exist because rel > 0
const nextItem = /** @type {Item} */ (listIter.nextItem)
if (listIter.rel === nextItem.length) {
left = nextItem
right = left.right
} else {
const structs = /** @type {Array<Item|GC>} */ (transaction.doc.store.clients.get(nextItem.id.client))
const after = /** @type {Item} */ (structs[findIndexCleanStart(transaction, structs, nextItem.id.clock + listIter.rel)])
listIter.nextItem = after
listIter.rel = 0
left = listIter.left
right = listIter.right
}
} else {
left = listIter.left
right = listIter.right
}
// @todo this should simply split if .rel > 0
return new ItemTextListPosition(left, right, index, currentAttributes)
})
const marker = findMarker(parent, index)
if (marker) {
const pos = new ItemTextListPosition(marker.p.left, marker.p, marker.index, currentAttributes)
return findNextPosition(transaction, pos, index - marker.index)
} else {
const pos = new ItemTextListPosition(null, parent._start, 0, currentAttributes)
return findNextPosition(transaction, pos, index)
@@ -285,7 +264,7 @@ const insertText = (transaction, parent, currPos, text, attributes) => {
const content = text.constructor === String ? new ContentString(/** @type {string} */ (text)) : (text instanceof AbstractType ? new ContentType(text) : new ContentEmbed(text))
let { left, right, index } = currPos
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength(), null)
updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength())
}
right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content)
right.integrate(transaction, 0)
@@ -490,7 +469,7 @@ const deleteText = (transaction, currPos, length) => {
}
const parent = /** @type {AbstractType<any>} */ (/** @type {Item} */ (currPos.left || currPos.right).parent)
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, currPos.index, -startLength + length, null)
updateMarkerChanges(parent._searchMarker, currPos.index, -startLength + length)
}
return currPos
}
@@ -785,7 +764,7 @@ export class YText extends AbstractType {
*/
this._pending = string !== undefined ? [() => this.insert(0, string)] : []
/**
* @type {Array<ListIterator>}
* @type {Array<ArraySearchMarker>}
*/
this._searchMarker = []
}

View File

@@ -8,13 +8,15 @@ import {
AbstractType,
typeListMap,
typeListForEach,
typeListInsertGenerics,
typeListInsertGenericsAfter,
typeListDelete,
typeListToArray,
YXmlFragmentRefID,
callTypeObservers,
transact,
typeListGet,
typeListSlice,
useSearchMarker,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
} from '../internals.js'
@@ -302,11 +304,9 @@ export class YXmlFragment extends AbstractType {
*/
insert (index, content) {
if (this.doc !== null) {
return transact(/** @type {Doc} */ (this.doc), transaction =>
useSearchMarker(transaction, this, index, walker =>
walker.insertArrayValue(transaction, content)
)
)
transact(this.doc, transaction => {
typeListInsertGenerics(transaction, this, index, content)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, 0, ...content)
@@ -347,11 +347,9 @@ export class YXmlFragment extends AbstractType {
*/
delete (index, length = 1) {
if (this.doc !== null) {
transact(/** @type {Doc} */ (this.doc), transaction =>
useSearchMarker(transaction, this, index, walker =>
walker.delete(transaction, length)
)
)
transact(this.doc, transaction => {
typeListDelete(transaction, this, index, length)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, length)
@@ -392,11 +390,7 @@ export class YXmlFragment extends AbstractType {
* @return {YXmlElement|YXmlText}
*/
get (index) {
return transact(/** @type {Doc} */ (this.doc), transaction =>
useSearchMarker(transaction, this, index, walker =>
walker.slice(transaction, 1)[0]
)
)
return typeListGet(this, index)
}
/**

View File

@@ -1,510 +0,0 @@
import * as error from 'lib0/error'
import {
getItemCleanStart,
createID,
getMovedCoords,
updateMarkerChanges,
getState,
ContentAny,
ContentBinary,
ContentType,
ContentDoc,
Doc,
RelativePosition, ID, AbstractContent, ContentMove, Transaction, Item, AbstractType // eslint-disable-line
} from '../internals.js'
const lengthExceeded = error.create('Length exceeded!')
/**
* @todo rename to walker?
* @todo check that inserting character one after another always reuses ListIterators
*/
export class ListIterator {
/**
* @param {AbstractType<any>} type
*/
constructor (type) {
this.type = type
/**
* Current index-position
*/
this.index = 0
/**
* Relative position to the current item (if item.content.length > 1)
*/
this.rel = 0
/**
* This refers to the current right item, unless reachedEnd is true. Then it refers to the left item.
*
* @public
* @type {Item | null}
*/
this.nextItem = type._start
this.reachedEnd = type._start === null
/**
* @type {Item | null}
*/
this.currMove = null
/**
* @type {Item | null}
*/
this.currMoveStart = null
/**
* @type {Item | null}
*/
this.currMoveEnd = null
/**
* @type {Array<{ start: Item | null, end: Item | null, move: Item }>}
*/
this.movedStack = []
}
clone () {
const iter = new ListIterator(this.type)
iter.index = this.index
iter.rel = this.rel
iter.nextItem = this.nextItem
iter.reachedEnd = this.reachedEnd
iter.currMove = this.currMove
iter.currMoveStart = this.currMoveStart
iter.currMoveEnd = this.currMoveEnd
iter.movedStack = this.movedStack.slice()
return iter
}
/**
* @type {Item | null}
*/
get left () {
if (this.reachedEnd) {
return this.nextItem
} else {
return this.nextItem && this.nextItem.left
}
}
/**
* @type {Item | null}
*/
get right () {
if (this.reachedEnd) {
return null
} else {
return this.nextItem
}
}
/**
* @param {Transaction} tr
* @param {number} index
*/
moveTo (tr, index) {
const diff = index - this.index
if (diff > 0) {
this.forward(tr, diff)
} else if (diff < 0) {
this.backward(tr, -diff)
}
}
/**
* @param {Transaction} tr
* @param {number} len
*/
forward (tr, len) {
if (this.index + len > this.type._length) {
throw lengthExceeded
}
let item = this.nextItem
this.index += len
if (this.rel) {
len += this.rel
this.rel = 0
}
while ((!this.reachedEnd || this.currMove !== null) && (len > 0 || (len === 0 && item && (!item.countable || item.deleted || item === this.currMoveEnd || (this.reachedEnd && this.currMoveEnd === null) || item.moved !== this.currMove)))) {
if (item === this.currMoveEnd || (this.currMoveEnd === null && this.reachedEnd && this.currMove)) {
item = /** @type {Item} */ (this.currMove) // we iterate to the right after the current condition
const { start, end, move } = this.movedStack.pop() || { start: null, end: null, move: null }
this.currMove = move
this.currMoveStart = start
this.currMoveEnd = end
this.reachedEnd = false
} else if (item === null) {
break
} else if (item.countable && !item.deleted && item.moved === this.currMove && len > 0) {
len -= item.length
if (len < 0) {
this.rel = item.length + len
len = 0
break
}
} else if (item.content.constructor === ContentMove && item.moved === this.currMove) {
if (this.currMove) {
this.movedStack.push({ start: this.currMoveStart, end: this.currMoveEnd, move: this.currMove })
}
const { start, end } = getMovedCoords(item.content, tr)
this.currMove = item
this.currMoveStart = start
this.currMoveEnd = end
item = start
continue
}
if (item.right) {
item = item.right
} else {
this.reachedEnd = true
}
}
this.index -= len
this.nextItem = item
return this
}
/**
* @param {Transaction} tr
*/
reduceMoves (tr) {
let item = this.nextItem
if (item !== null) {
while (item === this.currMoveStart) {
item = /** @type {Item} */ (this.currMove) // we iterate to the left after the current condition
const { start, end, move } = this.movedStack.pop() || { start: null, end: null, move: null }
this.currMove = move
this.currMoveStart = start
this.currMoveEnd = end
}
this.nextItem = item
}
}
/**
* @param {Transaction} tr
* @param {number} len
* @return {ListIterator}
*/
backward (tr, len) {
if (this.index - len < 0) {
throw lengthExceeded
}
this.index -= len
if (this.reachedEnd) {
const nextItem = /** @type {Item} */ (this.nextItem)
this.rel = nextItem.countable && !nextItem.deleted ? nextItem.length : 0
this.reachedEnd = false
}
if (this.rel >= len) {
this.rel -= len
return this
}
let item = this.nextItem && this.nextItem.left
if (this.rel) {
len -= this.rel
this.rel = 0
}
while (item && len > 0) {
if (item.countable && !item.deleted && item.moved === this.currMove) {
len -= item.length
if (len < 0) {
this.rel = -len
len = 0
}
if (len === 0) {
break
}
} else if (item.content.constructor === ContentMove && item.moved === this.currMove) {
if (this.currMove) {
this.movedStack.push({ start: this.currMoveStart, end: this.currMoveEnd, move: this.currMove })
}
const { start, end } = getMovedCoords(item.content, tr)
this.currMove = item
this.currMoveStart = start
this.currMoveEnd = end
item = start
continue
}
if (item === this.currMoveStart) {
item = /** @type {Item} */ (this.currMove) // we iterate to the left after the current condition
const { start, end, move } = this.movedStack.pop() || { start: null, end: null, move: null }
this.currMove = move
this.currMoveStart = start
this.currMoveEnd = end
}
item = item.left
}
this.nextItem = item
return this
}
/**
* @template {{length: number}} T
* @param {Transaction} tr
* @param {number} len
* @param {T} value the initial content
* @param {function(AbstractContent, number, number):T} slice
* @param {function(T, T): T} concat
*/
_slice (tr, len, value, slice, concat) {
this.index += len
while (len > 0 && !this.reachedEnd) {
while (this.nextItem && this.nextItem.countable && !this.reachedEnd && len > 0 && this.nextItem !== this.currMoveEnd) {
if (!this.nextItem.deleted && this.nextItem.moved === this.currMove) {
const item = this.nextItem
const slicedContent = slice(item.content, this.rel, len)
len -= slicedContent.length
value = concat(value, slicedContent)
if (item.length !== slicedContent.length) {
if (this.rel + slicedContent.length === item.length) {
this.rel = 0
} else {
this.rel += slicedContent.length
continue // do not iterate to item.right
}
}
}
if (this.nextItem.right) {
this.nextItem = this.nextItem.right
} else {
this.reachedEnd = true
}
}
if (this.nextItem && (!this.reachedEnd || this.currMove !== null) && len > 0) {
this.forward(tr, 0)
}
}
if (len < 0) {
this.index -= len
}
return value
}
/**
* @param {Transaction} tr
* @param {number} len
*/
delete (tr, len) {
const startLength = len
const sm = this.type._searchMarker
let item = this.nextItem
while (len > 0) {
while (item && !item.deleted && item.countable && !this.reachedEnd && len > 0 && item.moved === this.currMove && item !== this.currMoveEnd) {
if (this.rel > 0) {
item = getItemCleanStart(tr, createID(item.id.client, item.id.clock + this.rel))
this.rel = 0
}
if (len < item.length) {
getItemCleanStart(tr, createID(item.id.client, item.id.clock + len))
}
len -= item.length
item.delete(tr)
if (item.right) {
item = item.right
} else {
this.reachedEnd = true
}
}
if (len > 0) {
this.nextItem = item
this.forward(tr, 0)
item = this.nextItem
}
}
this.nextItem = item
if (sm) {
updateMarkerChanges(sm, this.index, -startLength + len, this)
}
}
/**
* @param {Transaction} tr
*/
_splitRel (tr) {
if (this.rel > 0) {
/**
* @type {ID}
*/
const itemid = /** @type {Item} */ (this.nextItem).id
this.nextItem = getItemCleanStart(tr, createID(itemid.client, itemid.clock + this.rel))
this.rel = 0
}
}
/**
* Important: you must update markers after calling this method!
*
* @param {Transaction} tr
* @param {Array<AbstractContent>} content
*/
insertContents (tr, content) {
this.reduceMoves(tr)
this._splitRel(tr)
const parent = this.type
const store = tr.doc.store
const ownClientId = tr.doc.clientID
/**
* @type {Item | null}
*/
const right = this.right
/**
* @type {Item | null}
*/
let left = this.left
content.forEach(c => {
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, c)
left.integrate(tr, 0)
})
if (right === null) {
this.nextItem = left
this.reachedEnd = true
} else {
this.nextItem = right
}
}
/**
* @param {Transaction} tr
* @param {RelativePosition} start
* @param {RelativePosition} end
*/
insertMove (tr, start, end) {
this.insertContents(tr, [new ContentMove(start, end, -1)]) // @todo adjust priority
// @todo is there a better alrogirthm to update searchmarkers? We could simply remove the markers that are in the updated range.
// Also note that searchmarkers are updated in insertContents as well.
const sm = this.type._searchMarker
if (sm) sm.length = 0
}
/**
* @param {Transaction} tr
* @param {Array<Object<string,any>|Array<any>|boolean|number|null|string|Uint8Array>} values
*/
insertArrayValue (tr, values) {
this._splitRel(tr)
const sm = this.type._searchMarker
/**
* @type {Array<AbstractContent>}
*/
const contents = []
/**
* @type {Array<Object|Array<any>|number|null>}
*/
let jsonContent = []
const packJsonContent = () => {
if (jsonContent.length > 0) {
contents.push(new ContentAny(jsonContent))
jsonContent = []
}
}
values.forEach(c => {
if (c === null) {
jsonContent.push(c)
} else {
switch (c.constructor) {
case Number:
case Object:
case Boolean:
case Array:
case String:
jsonContent.push(c)
break
default:
packJsonContent()
switch (c.constructor) {
case Uint8Array:
case ArrayBuffer:
contents.push(new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
break
case Doc:
contents.push(new ContentDoc(/** @type {Doc} */ (c)))
break
default:
if (c instanceof AbstractType) {
contents.push(new ContentType(c))
} else {
throw new Error('Unexpected content type in insert operation')
}
}
}
}
})
packJsonContent()
this.insertContents(tr, contents)
this.index += values.length
if (sm) {
updateMarkerChanges(sm, this.index - values.length, values.length, this)
}
}
/**
* @param {Transaction} tr
* @param {number} len
*/
slice (tr, len) {
return this._slice(tr, len, [], sliceArrayContent, concatArrayContent)
}
/**
* @param {Transaction} tr
* @param {function(any, number, any):void} f
*/
forEach (tr, f) {
for (const val of this.values(tr)) {
f(val, this.index, this.type)
}
}
/**
* @template T
* @param {Transaction} tr
* @param {function(any, number, any):T} f
* @return {Array<T>}
*/
map (tr, f) {
const arr = new Array(this.type._length - this.index)
let i = 0
for (const val of this.values(tr)) {
arr[i++] = f(val, this.index, this.type)
}
return arr
}
/**
* @param {Transaction} tr
*/
values (tr) {
return {
[Symbol.iterator] () {
return this
},
next: () => {
if (this.reachedEnd || this.index === this.type._length) {
return { done: true }
}
const [value] = this.slice(tr, 1)
return {
done: false,
value: value
}
}
}
}
}
/**
* @param {AbstractContent} itemcontent
* @param {number} start
* @param {number} len
*/
const sliceArrayContent = (itemcontent, start, len) => {
const content = itemcontent.getContent()
return content.length <= len && start === 0 ? content : content.slice(start, start + len)
}
/**
* @param {Array<any>} content
* @param {Array<any>} added
*/
const concatArrayContent = (content, added) => {
content.push(...added)
return content
}

View File

@@ -9,8 +9,6 @@ import {
createID,
ContentType,
followRedone,
transact,
useSearchMarker,
ID, Doc, AbstractType // eslint-disable-line
} from '../internals.js'
@@ -75,10 +73,6 @@ export class RelativePosition {
*/
this.assoc = assoc
}
clone () {
return new RelativePosition(this.type, this.tname, this.item, this.assoc)
}
}
/**
@@ -167,6 +161,7 @@ export const createRelativePosition = (type, item, assoc) => {
* @function
*/
export const createRelativePositionFromTypeIndex = (type, index, assoc = 0) => {
let t = type._start
if (assoc < 0) {
// associated to the left character or the beginning of a type, increment index if possible.
if (index === 0) {
@@ -174,17 +169,21 @@ export const createRelativePositionFromTypeIndex = (type, index, assoc = 0) => {
}
index--
}
return transact(/** @type {Doc} */ (type.doc), tr =>
useSearchMarker(tr, type, index, walker => {
if (walker.reachedEnd) {
const item = assoc < 0 ? /** @type {Item} */ (walker.nextItem).lastId : null
return createRelativePosition(type, item, assoc)
} else {
const id = /** @type {Item} */ (walker.nextItem).id
return createRelativePosition(type, createID(id.client, id.clock + walker.rel), assoc)
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
}
if (t.right === null && assoc < 0) {
// left-associated position, return last available id
return createRelativePosition(type, t.lastId, assoc)
}
t = t.right
}
return createRelativePosition(type, null, assoc)
}
/**

View File

@@ -199,18 +199,19 @@ export const getItemCleanStart = (transaction, id) => {
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
*
* @param {Transaction} transaction
* @param {StructStore} store
* @param {ID} id
* @return {Item}
*
* @private
* @function
*/
export const getItemCleanEnd = (transaction, id) => {
export const getItemCleanEnd = (transaction, store, id) => {
/**
* @type {Array<Item>}
*/
// @ts-ignore
const structs = transaction.doc.store.clients.get(id.client)
const structs = store.clients.get(id.client)
const index = findIndexSS(structs, id.clock)
const struct = structs[index]
if (id.clock !== struct.id.clock + struct.length - 1 && struct.constructor !== GC) {

View File

@@ -114,14 +114,6 @@ export class Transaction {
* @type {Set<Doc>}
*/
this.subdocsLoaded = new Set()
/**
* We store the reference that last moved an item.
* This is needed to compute the delta when multiple ContentMove move
* the same item.
*
* @type {Map<Item, Item>}
*/
this.prevMoved = new Map()
}
}
@@ -385,12 +377,9 @@ const cleanupTransactions = (transactionCleanups, i) => {
/**
* Implements the functionality of `y.transact(()=>{..})`
*
* @template T
*
* @param {Doc} doc
* @param {function(Transaction):T} f
* @param {function(Transaction):void} f
* @param {any} [origin=true]
* @return {T}
*
* @function
*/
@@ -406,9 +395,8 @@ export const transact = (doc, f, origin = null, local = true) => {
}
doc.emit('beforeTransaction', [doc._transaction, doc])
}
let res
try {
res = f(doc._transaction)
f(doc._transaction)
} finally {
if (initialCall && transactionCleanups[0] === doc._transaction) {
// The first transaction ended, now process observer calls.
@@ -422,5 +410,4 @@ export const transact = (doc, f, origin = null, local = true) => {
cleanupTransactions(transactionCleanups, 0)
}
}
return res
}

View File

@@ -1,8 +1,7 @@
import {
isDeleted,
getMovedCoords,
ContentMove, Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
} from '../internals.js'
import * as set from 'lib0/set'
@@ -154,107 +153,62 @@ export class YEvent {
get changes () {
let changes = this._changes
if (changes === null) {
this.transaction.doc.transact(tr => {
const target = this.target
const added = set.create()
const deleted = set.create()
const target = this.target
const added = set.create()
const deleted = set.create()
/**
* @type {Array<{insert:Array<any>}|{delete:number}|{retain:number}>}
*/
const delta = []
changes = {
added,
deleted,
delta,
keys: this.keys
}
const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
if (changed.has(null)) {
/**
* @type {Array<{insert:Array<any>}|{delete:number}|{retain:number}>}
* @type {any}
*/
const delta = []
changes = {
added,
deleted,
delta,
keys: this.keys
}
const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
if (changed.has(null)) {
/**
* @type {Array<{ end: Item | null, move: Item | null, isNew : boolean }>}
*/
const movedStack = []
/**
* @type {Item | null}
*/
let currMove = null
/**
* @type {boolean}
*/
let currMoveIsNew = false
/**
* @type {Item | null}
*/
let currMoveEnd = null
/**
* @type {any}
*/
let lastOp = null
const packOp = () => {
if (lastOp) {
delta.push(lastOp)
}
let lastOp = null
const packOp = () => {
if (lastOp) {
delta.push(lastOp)
}
for (let item = target._start; ;) {
if (item === currMoveEnd && currMove) {
item = currMove
const { end, move, isNew } = movedStack.pop() || { end: null, move: null, isNew: false }
currMoveIsNew = isNew
currMoveEnd = end
currMove = move
} else if (item === null) {
break
} else if (item.content.constructor === ContentMove) {
if (item.moved === currMove) {
movedStack.push({ end: currMoveEnd, move: currMove, isNew: currMoveIsNew })
const { start, end } = getMovedCoords(item.content, tr)
currMove = item
currMoveEnd = end
currMoveIsNew = this.adds(item)
item = start
continue // do not move to item.right
}
for (let item = target._start; item !== null; item = item.right) {
if (item.deleted) {
if (this.deletes(item) && !this.adds(item)) {
if (lastOp === null || lastOp.delete === undefined) {
packOp()
lastOp = { delete: 0 }
}
} else if (item.moved !== currMove) {
if (!currMoveIsNew && item.countable && item.moved && !this.adds(item) && this.adds(item.moved) && (this.transaction.prevMoved.get(item) || null) === currMove) {
if (lastOp === null || lastOp.delete === undefined) {
packOp()
lastOp = { delete: 0 }
}
lastOp.delete += item.length
}
} else if (item.deleted) {
if (!currMoveIsNew && this.deletes(item) && !this.adds(item)) {
if (lastOp === null || lastOp.delete === undefined) {
packOp()
lastOp = { delete: 0 }
}
lastOp.delete += item.length
deleted.add(item)
lastOp.delete += item.length
deleted.add(item)
} // else nop
} else {
if (this.adds(item)) {
if (lastOp === null || lastOp.insert === undefined) {
packOp()
lastOp = { insert: [] }
}
lastOp.insert = lastOp.insert.concat(item.content.getContent())
added.add(item)
} else {
if (currMoveIsNew || this.adds(item)) {
if (lastOp === null || lastOp.insert === undefined) {
packOp()
lastOp = { insert: [] }
}
lastOp.insert = lastOp.insert.concat(item.content.getContent())
added.add(item)
} else {
if (lastOp === null || lastOp.retain === undefined) {
packOp()
lastOp = { retain: 0 }
}
lastOp.retain += item.length
if (lastOp === null || lastOp.retain === undefined) {
packOp()
lastOp = { retain: 0 }
}
lastOp.retain += item.length
}
item = /** @type {Item} */ (item).right
}
if (lastOp !== null && lastOp.retain === undefined) {
packOp()
}
}
this._changes = changes
})
if (lastOp !== null && lastOp.retain === undefined) {
packOp()
}
}
this._changes = changes
}
return /** @type {any} */ (changes)
}

View File

@@ -308,7 +308,6 @@ export const mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = U
// Note: Should handle that some operations cannot be applied yet ()
while (true) {
// @todo this incurs an exponential overhead. We could instead only sort the item that changed.
// Write higher clients first ⇒ sort by clientID & clock and remove decoders without content
lazyStructDecoders = lazyStructDecoders.filter(dec => dec.curr !== null)
lazyStructDecoders.sort(

View File

@@ -40,7 +40,6 @@ export const testToJSON = tc => {
const arr = doc.getArray('array')
arr.push(['test1'])
t.compare(arr.toJSON(), ['test1'])
const map = doc.getMap('map')
map.set('k1', 'v1')

View File

@@ -12,7 +12,6 @@ import {
readContentFormat,
readContentAny,
readContentDoc,
readContentMove,
Doc,
PermanentUserData,
encodeStateAsUpdate,
@@ -25,8 +24,7 @@ import * as Y from '../src/index.js'
* @param {t.TestCase} tc
*/
export const testStructReferences = tc => {
t.assert(contentRefs.length === 12)
// contentRefs[0] is reserved for GC
t.assert(contentRefs.length === 11)
t.assert(contentRefs[1] === readContentDeleted)
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
t.assert(contentRefs[3] === readContentBinary)
@@ -37,7 +35,6 @@ export const testStructReferences = tc => {
t.assert(contentRefs[8] === readContentAny)
t.assert(contentRefs[9] === readContentDoc)
// contentRefs[10] is reserved for Skip structs
t.assert(contentRefs[11] === readContentMove)
}
/**

View File

@@ -373,33 +373,6 @@ export const compare = users => {
t.compare(Y.encodeStateVector(users[i]), Y.encodeStateVector(users[i + 1]))
compareDS(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store))
compareStructStores(users[i].store, users[i + 1].store)
// @todo
// test list-iterator
// console.log('dutiraneduiaentdr', users[0].getArray('array')._searchMarker)
/*
{
const user = users[0]
user.transact(tr => {
const type = user.getArray('array')
Y.useSearchMarker(tr, type, type.length, walker => {
for (let i = type.length; i >= 0; i--) {
const otherWalker = new Y.ListIterator(type)
otherWalker.forward(tr, walker.index)
otherWalker.forward(tr, 0)
walker.forward(tr, 0)
t.assert(walker.index === i)
t.assert(walker.left === otherWalker.left)
t.assert(walker.right === otherWalker.right)
t.assert(walker.nextItem === otherWalker.nextItem)
t.assert(walker.reachedEnd === otherWalker.reachedEnd)
if (i > 0) {
walker.backward(tr, 1)
}
}
})
})
}
*/
}
users.map(u => u.destroy())
}

View File

@@ -1,4 +1,4 @@
import { init, compare, applyRandomTests, Doc, UndoManager } from './testHelper.js' // eslint-disable-line
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
@@ -273,7 +273,7 @@ export const testUndoInEmbed = tc => {
*/
export const testUndoDeleteFilter = tc => {
/**
* @type {Array<Y.Map<any>>}
* @type {Y.Array<any>}
*/
const array0 = /** @type {any} */ (init(tc, { users: 3 }).array0)
const undoManager = new Y.UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })

View File

@@ -1,4 +1,4 @@
import { init, compare, applyRandomTests, Doc, AbstractType, TestConnector } from './testHelper.js' // eslint-disable-line
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
@@ -432,86 +432,6 @@ export const testEventTargetIsSetCorrectlyOnRemote = tc => {
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testMove = tc => {
{
// move in uninitialized type
const yarr = new Y.Array()
yarr.insert(0, [1, 2, 3])
yarr.move(1, 0)
// @ts-ignore
t.compare(yarr._prelimContent, [2, 1, 3])
}
const { array0, array1, users } = init(tc, { users: 3 })
/**
* @type {any}
*/
let event0 = null
/**
* @type {any}
*/
let event1 = null
array0.observe(event => {
event0 = event
})
array1.observe(event => {
event1 = event
})
array0.insert(0, [1, 2, 3])
array0.move(1, 0)
t.compare(array0.toArray(), [2, 1, 3])
t.compare(event0.delta, [{ insert: [2] }, { retain: 1 }, { delete: 1 }])
Y.applyUpdate(users[1], Y.encodeStateAsUpdate(users[0]))
t.compare(array1.toArray(), [2, 1, 3])
t.compare(event1.delta, [{ insert: [2, 1, 3] }])
array0.move(0, 2)
t.compare(array0.toArray(), [1, 2, 3])
t.compare(event0.delta, [{ delete: 1 }, { retain: 1 }, { insert: [2] }])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testMove2 = tc => {
{
// move in uninitialized type
const yarr = new Y.Array()
yarr.insert(0, [1, 2])
yarr.move(1, 0)
// @ts-ignore
t.compare(yarr._prelimContent, [2, 1])
}
const { array0, array1, users } = init(tc, { users: 3 })
/**
* @type {any}
*/
let event0 = null
/**
* @type {any}
*/
let event1 = null
array0.observe(event => {
event0 = event
})
array1.observe(event => {
event1 = event
})
array0.insert(0, [1, 2])
array0.move(1, 0)
t.compare(array0.toArray(), [2, 1])
t.compare(event0.delta, [{ insert: [2] }, { retain: 1 }, { delete: 1 }])
Y.applyUpdate(users[1], Y.encodeStateAsUpdate(users[0]))
t.compare(array1.toArray(), [2, 1])
t.compare(event1.delta, [{ insert: [2, 1] }])
array0.move(0, 2)
t.compare(array0.toArray(), [1, 2])
t.compare(event0.delta, [{ delete: 1 }, { retain: 1 }, { insert: [2] }])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
@@ -536,23 +456,8 @@ const getUniqueNumber = () => _uniqueNumber++
/**
* @type {Array<function(Doc,prng.PRNG,any):void>}
*
* @todo to replace content to a separate data structure so we know that insert & returns work as expected!!!
*/
const arrayTransactions = [
function move (user, gen) {
const yarray = user.getArray('array')
if (yarray.length === 0) {
return
}
const pos = prng.int32(gen, 0, yarray.length - 1)
const newPos = prng.int32(gen, 0, yarray.length)
const oldContent = yarray.toArray()
yarray.move(pos, newPos)
const [x] = oldContent.splice(pos, 1)
oldContent.splice(pos < newPos ? newPos - 1 : newPos, 0, x)
t.compareArrays(yarray.toArray(), oldContent) // we want to make sure that fastSearch markers insert at the correct position
},
function insert (user, gen) {
const yarray = user.getArray('array')
const uniqueNumber = getUniqueNumber()
@@ -611,49 +516,11 @@ const arrayTransactions = [
}
]
/**
* @param {Y.Doc} user
*/
const monitorArrayTestObject = user => {
/**
* @type {Array<any>}
*/
const arr = []
const yarr = user.getArray('array')
yarr.observe(event => {
let currpos = 0
const delta = event.delta
for (let i = 0; i < delta.length; i++) {
const d = delta[i]
if (d.insert != null) {
arr.splice(currpos, 0, ...(/** @type {Array<any>} */ (d.insert)))
currpos += /** @type {Array<any>} */ (d.insert).length
} else if (d.retain != null) {
currpos += d.retain
} else {
arr.splice(currpos, d.delete)
}
}
})
return arr
}
/**
* @param {{ testObjects: Array<Array<any>>, users: Array<Y.Doc> }} cmp
*/
const compareTestobjects = cmp => {
const arrs = cmp.testObjects
for (let i = 0; i < arrs.length; i++) {
const type = cmp.users[i].getArray('array')
t.compareArrays(arrs[i], type.toArray())
}
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests6 = tc => {
compareTestobjects(applyRandomTests(tc, arrayTransactions, 7, monitorArrayTestObject))
applyRandomTests(tc, arrayTransactions, 6)
}
/**

View File

@@ -327,7 +327,6 @@ export const testFormattingDeltaUnnecessaryAttributeChange = tc => {
* @param {t.TestCase} tc
*/
export const testInsertAndDeleteAtRandomPositions = tc => {
// @todo optimize to run at least as fast as previous marker approach
const N = 100000
const { text0 } = init(tc, { users: 1 })
const gen = tc.prng
@@ -553,6 +552,8 @@ export const testSearchMarkerBug1 = tc => {
}
/**
* Reported in https://github.com/yjs/yjs/pull/32
*
* @param {t.TestCase} tc
*/
export const testFormattingBug = async tc => {
@@ -562,6 +563,7 @@ export const testFormattingBug = async tc => {
text1.insert(0, '\n\n\n')
text1.format(0, 3, { url: 'http://example.com' })
ydoc1.getText().format(1, 1, { url: 'http://docs.yjs.dev' })
ydoc2.getText().format(1, 1, { url: 'http://docs.yjs.dev' })
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc1))
const text2 = ydoc2.getText()
const expectedResult = [