Compare commits

...

103 Commits

Author SHA1 Message Date
Kevin Jahns
56d747faea 14.0.0-0 2022-08-18 18:17:12 +02:00
Kevin Jahns
a3b97d941b rename to ListCursor 2022-07-19 14:49:46 +02:00
Kevin Jahns
efcfe4b483 extend move info field by three bits for future usage 2022-07-19 11:07:56 +02:00
Kevin Jahns
4de3c004a8 remove todo comment 2022-07-11 18:42:29 +02:00
Kevin Jahns
100e436e2c cleanup 2022-07-11 18:36:42 +02:00
Kevin Jahns
3b31764b6e Fixed all tests - full support for collapsed move deletions 2022-07-09 16:57:56 +02:00
Kevin Jahns
19723670c4 fix several issues of supporting deleted move ops 2022-07-08 21:36:36 +02:00
Kevin Jahns
0ce40596d1 remove old move-ranges if collapsed 2022-07-06 18:57:12 +02:00
Kevin Jahns
4078e115c1 optimize encoding of move ops 2022-07-04 16:44:01 +02:00
Kevin Jahns
ab5061cd47 normalize ranges 2022-07-04 16:05:44 +02:00
Kevin Jahns
44499cb9fe fix move-range tests of moved-moved items 2022-06-16 20:23:34 +02:00
Kevin Jahns
b63d22e7db lint 2022-05-18 10:50:36 +02:00
Kevin Jahns
bf05061cc7 fix all tests for range-moves of length 1 2022-05-18 10:44:12 +02:00
Kevin Jahns
7e9319f82e filter empty ranges when calculating minMoveRanges 2022-05-15 21:04:02 +02:00
Kevin Jahns
2e9a7df603 use uint8 type encoding for relative-positions 2022-05-10 15:31:01 +02:00
Kevin Jahns
1f99e8203a fix a bunch of issues with range-move approach 2022-05-07 16:14:18 +02:00
Kevin Jahns
69b7f4bfb9 implement solid move-range approach - tests not running 2022-05-05 13:03:59 +02:00
Kevin Jahns
b2b7b8c280 tmp commit 2022-04-11 15:54:10 +02:00
Kevin Jahns
a0c9235a36 fix test-move logic 2022-04-04 16:35:50 +02:00
Kevin Jahns
e8ecc8f74b fix circlic move-loops 2022-04-04 15:35:23 +02:00
Kevin Jahns
b32f88cd40 fix all remaining bugs for single-item moves (mainly event bugs) 2022-04-04 13:10:43 +02:00
Kevin Jahns
51c095ec52 fix search marker issues - splitting of items with stored rel search markers 2022-03-31 08:35:24 +02:00
Kevin Jahns
285dc79a6b fix edge case when moving backwards from move operation 2022-03-30 10:07:55 +02:00
Kevin Jahns
f65d1b8475 fix ListIterator backwards iteration within moved ranges 2022-03-29 16:55:22 +02:00
Kevin Jahns
c4b28aceec fix prevMove bug 2022-03-26 11:03:28 +01:00
Kevin Jahns
cc93f346ce 13.6.0-2 2021-12-08 16:12:11 +01:00
Kevin Jahns
d3dcd24ef4 fix various tests 2021-12-08 16:10:49 +01:00
Kevin Jahns
6fc4fbd466 13.6.0-1 2021-12-07 13:54:22 +01:00
Kevin Jahns
53e2c83f86 add meta property to AbstractType 2021-12-07 13:53:28 +01:00
Kevin Jahns
24bca2af43 13.6.0-0 2021-12-07 12:42:32 +01:00
Kevin Jahns
b75682022e skip tests in preversion (should be handled by np) 2021-12-07 12:41:40 +01:00
Kevin Jahns
3d31ba8759 adding more sanity checkss to yarray.tests 2021-12-07 12:37:03 +01:00
Kevin Jahns
bd47efe0ee fix all tests 2021-12-06 22:21:55 +01:00
Kevin Jahns
f5781f8366 update searchmarkers after insert correctly 2021-12-06 22:07:46 +01:00
Kevin Jahns
6230abb78c make sure that markers are correct without reinit 2021-12-06 21:22:18 +01:00
Kevin Jahns
4356d70ed0 reinit search marker after transaction 2021-12-06 21:00:20 +01:00
Kevin Jahns
0948229422 handle nested moves 2021-12-06 15:07:43 +01:00
Kevin Jahns
fc5e36158f made simple one-time move work 2021-12-06 15:07:43 +01:00
Kevin Jahns
d314c3e1a6 fixed ListIterator.reachedEnd edge case 2021-12-06 15:07:43 +01:00
Kevin Jahns
2a33507c00 fixed pos.rel cases 2021-12-06 15:07:43 +01:00
Kevin Jahns
40c3be1732 fix backwards edge case 2021-12-06 15:07:43 +01:00
Kevin Jahns
4a8ebc31f7 fix listiterator.map returning undefined as the last element 2021-12-06 15:07:43 +01:00
Kevin Jahns
6df152c4ec proper iteration through arrays (for mappings, toJSON, ..) 2021-12-06 15:07:43 +01:00
Kevin Jahns
fc38f3b848 formatting bug 2021-12-06 15:07:43 +01:00
Kevin Jahns
a057bf1cf0 fix disconnect issue 2021-12-06 15:07:43 +01:00
Kevin Jahns
8b82c573c4 fix basic inserd & delete bug 2021-12-06 15:07:43 +01:00
Kevin Jahns
a77221ffd2 fix toJSON value 2021-12-06 15:07:42 +01:00
Kevin Jahns
b9ccbb2dc7 created new abstraction for search marker 2021-12-06 15:06:17 +01:00
Kevin Jahns
a723c32557 use new ListPosition abstraction in Y.Array .slice and .get 2021-12-06 15:06:17 +01:00
Kevin Jahns
56ab251e79 make moved a separate prop on item 2021-12-06 15:06:17 +01:00
Kevin Jahns
53a7b286b8 Move content and list iteration abstraction 2021-12-06 15:06:13 +01:00
Kevin Jahns
294ba351b6 Merge branch 'main' of github.com:yjs/yjs 2021-11-27 11:38:00 +01:00
Kevin Jahns
610e532868 add test case for nested Y.Text as embeds. 2021-11-27 11:37:11 +01:00
Kevin Jahns
f73fb4796b Merge pull request #351 from haved/remove-cattaz-link
Remove broken link to terminated cattaz website
2021-11-27 10:22:17 +01:00
HKrogstie
32d391d7ab remove broken link to terminated cattaz
Someone else bought the domain and redirects it to a crummy google results reskin with ads.

According to the FujitsuLaboratories/cattaz repo, the website has been terminated.
2021-11-26 19:09:45 +01:00
Kevin Jahns
28e1b19e57 add loading event logic 2021-11-24 23:15:55 +01:00
Kevin Jahns
e90d9de5ed 13.5.22 2021-11-19 13:48:52 +01:00
Kevin Jahns
9a7250f192 fix undoing of content containing subdocs 2021-11-19 13:47:10 +01:00
Kevin Jahns
4154b12f14 handle local/remote autoload edge cases 2021-11-19 13:27:14 +01:00
Kevin Jahns
9df5016667 13.5.21 2021-11-15 14:00:04 +01:00
Kevin Jahns
1becaccdd9 bump lib0 dependency for bugfix dmonad/lib0#34 2021-11-15 13:58:22 +01:00
Kevin Jahns
ea4e9a0007 change order of logging statement for debugging 2021-11-14 13:10:52 +01:00
Kevin Jahns
a4e48d1ddf 13.5.20 2021-11-09 16:53:35 +01:00
Kevin Jahns
0a39a92b33 export testHelper 2021-11-09 16:51:54 +01:00
Kevin Jahns
bd819243eb 13.5.19 2021-11-09 16:39:58 +01:00
Kevin Jahns
2ec19defcb export testHelper esm properly 2021-11-06 15:55:59 +01:00
Kevin Jahns
336f7b1b1d 13.5.18 2021-11-06 14:39:55 +01:00
Kevin Jahns
8abf5b85ff fix #344 - formatting attribute assign bug 2021-11-06 14:35:04 +01:00
Kevin Jahns
320e8cbe18 add transaction to subdocs event 2021-11-02 23:24:28 +01:00
Kevin Jahns
49150f4adb add ydoc as argument in subdocs event 2021-10-29 22:04:59 +02:00
Kevin Jahns
e22fed7af3 13.5.17 2021-10-29 21:55:55 +02:00
Kevin Jahns
c91945228f inherid collectionid 2021-10-29 21:53:21 +02:00
Kevin Jahns
3586d91925 fire subdocs event only when something changed 2021-10-29 17:49:30 +02:00
Kevin Jahns
f915ebda1b 13.5.16 2021-10-15 19:18:51 +02:00
Kevin Jahns
a9b92b9099 13.5.15 2021-10-15 19:17:08 +02:00
Kevin Jahns
cbddf6ef90 add warning when Yjs was already imported 2021-10-15 19:10:11 +02:00
Kevin Jahns
491cd422c4 13.5.14 2021-10-14 16:21:02 +02:00
Kevin Jahns
4b88e2aac5 update dependencies 2021-10-14 16:19:24 +02:00
Kevin Jahns
e33c67fc72 bump standard linter 2021-10-14 16:18:50 +02:00
Kevin Jahns
085dda4cbd fix formatting test case #326 2021-10-14 16:09:23 +02:00
Kevin Jahns
f382846874 Merge pull request #326 from raedle/main
[tests] Encode/decode doc with attribute changes
2021-10-14 15:13:32 +02:00
Kevin Jahns
9afc5cf615 Merge pull request #331 from thomaswelter/main
Add support for null values in Y.Map and Y.Array
2021-10-14 15:08:20 +02:00
Kevin Jahns
ca0fb4b15d Merge pull request #332 from yjs/types-as-embeds
Allow types as Y.Text embeds
2021-10-14 15:04:26 +02:00
Kevin Jahns
d369a771a9 Merge pull request #335 from benmerckx/fix/array_map_dts
Remove T from JSDoc template, fixes #334
2021-10-14 15:00:38 +02:00
Kevin Jahns
995fbfa4cc Proper follow redones in nested redos - fixes #317 2021-10-14 14:59:26 +02:00
Kevin Jahns
7486ea7148 Merge pull request #339 from ViktorQvarfordt/patch-1
Fix type annotation of getMap
2021-10-12 22:26:54 +02:00
Viktor Qvarfordt
2c80a955da Fix type annotation of getMap
Make `getMap<T>()` take a generic type parameter just like `getArray<T>()`.
2021-10-10 11:05:18 +02:00
Kevin Jahns
233872493b Merge pull request #338 from nikgraf/patch-2
escape the plus to not interpret it as a list item
2021-10-08 17:53:58 +02:00
Nik Graf
64d164a904 escape the plus to not interpret it as a list item 2021-10-08 17:09:20 +02:00
Kevin Jahns
a08e54c2fc 13.5.13 2021-10-07 11:34:29 +02:00
Kevin Jahns
2b377cd46d export findIndexSS 2021-10-07 11:31:40 +02:00
Ben Merckx
b4b8927550 Remove T from JSDoc template, fixes #334 2021-09-28 12:36:01 +02:00
Kevin Jahns
b2761b50f2 more complex embed test 2021-09-25 11:58:39 +02:00
Kevin Jahns
28a9ce962d import from internals 2021-09-25 11:53:16 +02:00
Kevin Jahns
0ec67170d3 allow types as Y.Text embeds 2021-09-25 11:51:08 +02:00
Kevin Jahns
df9bfbe778 Merge branch 'main' of github.com:yjs/yjs 2021-09-23 22:14:42 +02:00
Kevin Jahns
f1ab417570 mergeUpdates on array with single entry just returns the first entry 2021-09-23 22:14:29 +02:00
Thomas Welter
4922eeac56 Add support for null values in Y.Map and Y.Array 2021-09-21 14:23:58 +02:00
Roman Rädle
57d6c6f831 [tests] Encode/decode doc with attribute changes
Encode a document with a text and decode it into a new document. Then, test if the same change to both documents results in the same text deltas.
2021-08-21 20:18:23 -07:00
Kevin Jahns
371f2b6d55 Merge pull request #318 from dai-shi/patch-1
Add valtio-yjs binding in README
2021-08-15 16:59:57 +02:00
Kevin Jahns
85a7ad148f Merge pull request #323 from vivaxy/bugfix/readme
Update an algorithm example
2021-08-09 15:05:33 +02:00
vivaxy
7ec1b3a19e docs: update an algorithm example 2021-08-09 19:33:45 +08:00
Daishi Kato
15ea4ee805 Update README.md 2021-07-25 10:08:43 +09:00
36 changed files with 7266 additions and 1739 deletions

View File

@@ -168,7 +168,7 @@ An implementation of the syncing process is in
## Snapshots
A snapshot can be used to restore an old document state. It is a `state vector`
+ `delete set`. I client can restore an old document state by iterating through
\+ `delete set`. I client can restore an old document state by iterating through
the sequence CRDT and ignoring all Items that have an `id.clock >
stateVector[id.client].clock`. Instead of using `item.deleted` the client will
use the delete set to find out if an item was deleted or not.

View File

@@ -65,8 +65,6 @@ Sponsorship also comes with special perks! [![Become a Sponsor](https://img.shie
* [JoeDocs](https://joedocs.com/) An open collaborative wiki.
* [Pluxbox RadioManager](https://pluxbox.com/) A web-based app to
collaboratively organize radio broadcasts.
* [Cattaz](http://cattaz.io/) A wiki that can run custom applications in the
wiki pages.
* [Alldone](https://alldone.app/) A next-gen project management and
collaboration platform.
@@ -100,6 +98,7 @@ are implemented in separate modules.
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](https://github.com/yjs/y-codemirror) | [demo](https://demos.yjs.dev/codemirror/codemirror.html) |
| [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) |
### Providers
@@ -249,36 +248,36 @@ necessary.
<dl>
<b><code>parent:Y.AbstractType|null</code></b>
<dd></dd>
<b><code>insert(index:number, content:Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<b><code>insert(index:number, content:Array&lt;object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;)</code></b>
<dd>
Insert content at <var>index</var>. Note that content is an array of elements.
I.e. <code>array.insert(0, [1])</code> splices the list and inserts 1 at
position 0.
</dd>
<b><code>push(Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<b><code>push(Array&lt;Object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;)</code></b>
<dd></dd>
<b><code>unshift(Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<b><code>unshift(Array&lt;Object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>slice(start:number, end:number):Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;</code></b>
<b><code>slice(start:number, end:number):Array&lt;Object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;</code></b>
<dd>Retrieve a range of content</dd>
<b><code>length:number</code></b>
<dd></dd>
<b>
<code>
forEach(function(value:object|boolean|Array|string|number|Uint8Array|Y.Type,
forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type,
index:number, array: Y.Array))
</code>
</b>
<dd></dd>
<b><code>map(function(T, number, YArray):M):Array&lt;M&gt;</code></b>
<dd></dd>
<b><code>toArray():Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;</code></b>
<b><code>toArray():Array&lt;object|boolean|Array|string|number|null|Uint8Array|Y.Type&gt;</code></b>
<dd>Copies the content of this YArray to a new Array.</dd>
<b><code>toJSON():Array&lt;Object|boolean|Array|string|number&gt;</code></b>
<b><code>toJSON():Array&lt;Object|boolean|Array|string|number|null&gt;</code></b>
<dd>
Copies the content of this YArray to a new Array. It transforms all child types
to JSON using their <code>toJSON</code> method.
@@ -324,9 +323,9 @@ or any of its children.
<dd></dd>
<b><code>size: number</code></b>
<dd>Total number of key/value pairs.</dd>
<b><code>get(key:string):object|boolean|string|number|Uint8Array|Y.Type</code></b>
<b><code>get(key:string):object|boolean|string|number|null|Uint8Array|Y.Type</code></b>
<dd></dd>
<b><code>set(key:string, value:object|boolean|string|number|Uint8Array|Y.Type)</code></b>
<b><code>set(key:string, value:object|boolean|string|number|null|Uint8Array|Y.Type)</code></b>
<dd></dd>
<b><code>delete(key:string)</code></b>
<dd></dd>
@@ -338,12 +337,12 @@ or any of its children.
<dd>Removes all elements from this YMap.</dd>
<b><code>clone():Y.Map</code></b>
<dd>Clone this type into a fresh Yjs type.</dd>
<b><code>toJSON():Object&lt;string, Object|boolean|Array|string|number|Uint8Array&gt;</code></b>
<b><code>toJSON():Object&lt;string, Object|boolean|Array|string|number|null|Uint8Array&gt;</code></b>
<dd>
Copies the <code>[key,value]</code> pairs of this YMap to a new Object.It
transforms all child types to JSON using their <code>toJSON</code> method.
</dd>
<b><code>forEach(function(value:object|boolean|Array|string|number|Uint8Array|Y.Type,
<b><code>forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type,
key:string, map: Y.Map))</code></b>
<dd>
Execute the provided function once for every key-value pair.
@@ -988,7 +987,7 @@ order of the structs anymore (e.g. if the parent was deleted).
**Examples:**
1. If a user inserts elements in sequence, the struct will be merged into a
single struct. E.g. `array.insert(0, ['a']), array.insert(0, ['b']);` is
single struct. E.g. `text.insert(0, 'a'), text.insert(1, 'b');` is
first represented as two structs (`[{id: {client, clock: 0}, content: 'a'},
{id: {client, clock: 1}, content: 'b'}`) and then merged into a single
struct: `[{id: {client, clock: 0}, content: 'ab'}]`.

5744
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
{
"name": "yjs",
"version": "13.5.12",
"version": "14.0.0-0",
"description": "Shared Editing Library",
"main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs",
"types": "./dist/src/index.d.ts",
"type": "module",
"sideEffects": false,
"funding": {
"type": "GitHub Sponsors ❤",
@@ -18,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 && node ./dist/tests.cjs --repetition-time 1000 && 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 && 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",
@@ -31,6 +32,7 @@
},
"./src/index.js": "./src/index.js",
"./tests/testHelper.js": "./tests/testHelper.js",
"./testHelper": "./dist/testHelper.mjs",
"./package.json": "./package.json"
},
"files": [
@@ -38,6 +40,7 @@
"dist/src",
"src",
"tests/testHelper.js",
"dist/testHelper.mjs",
"sponsor-y.js"
],
"dictionaries": {
@@ -71,19 +74,19 @@
},
"homepage": "https://docs.yjs.dev",
"dependencies": {
"lib0": "^0.2.41"
"lib0": "^0.2.43"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.2.1",
"concurrently": "^3.6.1",
"http-server": "^0.12.3",
"jsdoc": "^3.6.6",
"jsdoc": "^3.6.7",
"markdownlint-cli": "^0.23.2",
"rollup": "^2.47.0",
"standard": "^14.3.4",
"rollup": "^2.60.0",
"standard": "^16.0.4",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^4.2.4",
"y-protocols": "^1.0.4"
"typescript": "^4.4.4",
"y-protocols": "^1.0.5"
}
}

View File

@@ -60,6 +60,23 @@ export default [{
sourcemap: true
},
external: id => /^lib0\//.test(id)
}, {
input: './tests/testHelper.js',
output: {
name: 'Y',
file: 'dist/testHelper.mjs',
format: 'esm',
sourcemap: true
},
external: id => /^lib0\//.test(id) || id === 'yjs',
plugins: [{
resolveId (importee) {
if (importee === '../src/index.js') {
return 'yjs'
}
return null
}
}]
}, {
input: './tests/index.js',
output: {

View File

@@ -1,3 +1,4 @@
/** eslint-env browser */
export {
Doc,
@@ -26,12 +27,13 @@ export {
ContentString,
ContentType,
AbstractType,
RelativePosition,
getTypeChildren,
createRelativePositionFromTypeIndex,
createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition,
compareRelativePositions,
AbsolutePosition,
RelativePosition,
ID,
createID,
compareIDs,
@@ -40,11 +42,12 @@ export {
createSnapshot,
createDeleteSet,
createDeleteSetFromStructStore,
cleanupYTextFormatting,
snapshot,
emptySnapshot,
findRootTypeKey,
findIndexSS,
getItem,
typeListToArraySnapshot,
typeMapGetSnapshot,
createDocFromSnapshot,
iterateDeletedStructs,
@@ -83,3 +86,25 @@ export {
diffUpdate,
diffUpdateV2
} from './internals.js'
const glo = /** @type {any} */ (typeof window !== 'undefined'
? window
: typeof global !== 'undefined' ? global : {})
const importIdentifier = '__ $YJS$ __'
if (glo[importIdentifier] === true) {
/**
* Dear reader of this warning message. Please take this seriously.
*
* If you see this message, please make sure that you only import one version of Yjs. In many cases,
* your package manager installs two versions of Yjs that are used by different packages within your project.
* Another reason for this message is that some parts of your project use the commonjs version of Yjs
* and others use the EcmaScript version of Yjs.
*
* This often leads to issues that are hard to debug. We often need to perform constructor checks,
* e.g. `struct instanceof GC`. If you imported different versions of Yjs, it is impossible for us to
* do the constructor checks anymore - which might break the CRDT algorithm.
*/
console.warn('Yjs was already imported. Importing different versions of Yjs often leads to issues.')
}
glo[importIdentifier] = true

View File

@@ -8,6 +8,7 @@ export * from './utils/encoding.js'
export * from './utils/EventHandler.js'
export * from './utils/ID.js'
export * from './utils/isParentOf.js'
export * from './utils/ListCursor.js'
export * from './utils/logging.js'
export * from './utils/PermanentUserData.js'
export * from './utils/RelativePosition.js'
@@ -38,6 +39,7 @@ 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

@@ -5,6 +5,12 @@ import {
import * as error from 'lib0/error'
/**
* @param {string} guid
* @param {Object<string, any>} opts
*/
const createDocFromOpts = (guid, opts) => new Doc({ guid, ...opts, shouldLoad: opts.shouldLoad || opts.autoLoad || false })
/**
* @private
*/
@@ -61,7 +67,7 @@ export class ContentDoc {
* @return {ContentDoc}
*/
copy () {
return new ContentDoc(this.doc)
return new ContentDoc(createDocFromOpts(this.doc.guid, this.opts))
}
/**
@@ -132,4 +138,4 @@ export class ContentDoc {
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentDoc}
*/
export const readContentDoc = decoder => new ContentDoc(new Doc({ guid: decoder.readString(), ...decoder.readAny() }))
export const readContentDoc = decoder => new ContentDoc(createDocFromOpts(decoder.readString(), decoder.readAny()))

296
src/structs/ContentMove.js Normal file
View File

@@ -0,0 +1,296 @@
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 {
writeID,
readID,
ID, AbstractType, ContentType, RelativePosition, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore, getItem, getItemCleanStart, getItemCleanEnd, // eslint-disable-line
addsStruct
} from '../internals.js'
/**
* @param {ContentMove | { start: RelativePosition, end: RelativePosition }} moved
* @param {Transaction} tr
* @param {boolean} split
* @return {{ start: Item, end: Item }} $start (inclusive) is the beginning and $end (inclusive) is the end of the moved area
*/
export const getMovedCoords = (moved, tr, split) => {
const store = tr.doc.store
const startItem = moved.start.item
const endItem = moved.end.item
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 (startItem) {
if (moved.start.assoc < 0) {
// We know that the items have already been split, hence getItem suffices.
start = split ? getItemCleanEnd(tr, startItem) : getItem(store, startItem)
start = start.right
} else {
start = split ? getItemCleanStart(tr, startItem) : getItem(store, startItem)
}
} else if (moved.start.tname != null) {
start = tr.doc.get(moved.start.tname)._start
} else if (moved.start.type) {
start = /** @type {ContentType} */ (getItem(store, moved.start.type).content).type._start
} else {
error.unexpectedCase()
}
if (endItem) {
if (moved.end.assoc < 0) {
end = split ? getItemCleanEnd(tr, endItem) : getItem(store, endItem)
end = end.right
} else {
end = split ? getItemCleanStart(tr, endItem) : getItem(store, endItem)
}
} else {
error.unexpectedCase()
}
return { start: /** @type {Item} */ (start), end: /** @type {Item} */ (end) }
}
/**
* @param {Transaction} tr
* @param {ContentMove} moved
* @param {Item} movedItem
* @param {Set<Item>} trackedMovedItems
* @return {boolean} true if there is a loop
*/
export const findMoveLoop = (tr, moved, movedItem, trackedMovedItems) => {
if (trackedMovedItems.has(movedItem)) {
return true
}
trackedMovedItems.add(movedItem)
/**
* @type {{ start: Item | null, end: Item | null }}
*/
let { start, end } = getMovedCoords(moved, tr, false)
while (start !== end && start != null) {
if (
!start.deleted &&
start.moved === movedItem &&
start.content.constructor === ContentMove &&
findMoveLoop(tr, start.content, start, trackedMovedItems)
) {
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) {
const sm = /** @type {AbstractType<any>} */ (item.parent)._searchMarker
if (sm) sm.length = 0
const movedCoords = getMovedCoords(this, transaction, true)
/**
* @type {{ start: Item | null, end: item | null }}
*/
let { start, end } = movedCoords
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) {
const prevMove = start.moved // this is the same as prevMove
const nextPrio = prevMove ? /** @type {ContentMove} */ (prevMove.content).priority : -1
if (adaptPriority || nextPrio < this.priority || (prevMove != null && nextPrio === this.priority && (prevMove.id.client < item.id.client || (prevMove.id.client === item.id.client && prevMove.id.clock < item.id.clock)))) {
if (prevMove !== null) {
if (/** @type {ContentMove} */ (prevMove.content).isCollapsed()) {
prevMove.deleteAsCleanup(transaction, adaptPriority)
}
this.overrides.add(prevMove)
if (start !== movedCoords.start) {
// only add this to mergeStructs if this is not the first item
transaction._mergeStructs.push(start)
}
}
maxPriority = math.max(maxPriority, nextPrio)
// was already moved
if (prevMove && !transaction.prevMoved.has(start) && !addsStruct(transaction, prevMove)) {
// only override prevMoved if the prevMoved item is not new
// we need to know which item previously moved an item
transaction.prevMoved.set(start, prevMove)
}
start.moved = item
if (!start.deleted && start.content.constructor === ContentMove && findMoveLoop(transaction, start.content, start, new Set([item]))) {
item.deleteAsCleanup(transaction, adaptPriority)
return
}
} else if (prevMove != null) {
/** @type {ContentMove} */ (prevMove.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, false)
while (start !== end && start != null) {
if (start.moved === item) {
const prevMoved = transaction.prevMoved.get(start)
if (addsStruct(transaction, item)) {
if (prevMoved === item) {
// Edge case: Item has been moved by this move op and it has been created & deleted in the same transaction (hence no effect that should be emitted by the change computation)
transaction.prevMoved.delete(start)
}
} else if (prevMoved == null) { // && !addsStruct(tr, item)
// Normal case: item has been moved by this move and it has not been created & deleted in the same transaction
transaction.prevMoved.set(start, item)
}
start.moved = null
}
start = start.right
}
/**
* @param {Item} reIntegrateItem
*/
const reIntegrate = reIntegrateItem => {
const content = /** @type {ContentMove} */ (reIntegrateItem.content)
// content is not yet transformed to a ContentDeleted
if (content.getRef() === 11) {
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.writeVarUint(encoder.restEncoder, (isCollapsed ? 1 : 0) | (this.start.assoc >= 0 ? 2 : 0) | (this.end.assoc >= 0 ? 4 : 0) | this.priority << 6)
writeID(encoder.restEncoder, /** @type {ID} */ (this.start.item))
if (!isCollapsed) {
writeID(encoder.restEncoder, /** @type {ID} */ (this.end.item))
}
}
/**
* @return {number}
*/
getRef () {
return 11
}
isCollapsed () {
return this.start.item === this.end.item && this.start.item !== null
}
}
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentMove}
*/
export const readContentMove = decoder => {
const info = decoding.readVarUint(decoder.restDecoder)
const isCollapsed = (info & 1) === 1
const startAssoc = (info & 2) === 2 ? 0 : -1
const endAssoc = (info & 4) === 4 ? 0 : -1
// @TODO use BIT3 & BIT4 to indicate the case `null` is the start/end
// BIT5 is reserved for future extensions
const priority = info >>> 6
const startId = readID(decoder.restDecoder)
const start = new RelativePosition(null, null, startId, startAssoc)
const end = new RelativePosition(null, null, isCollapsed ? startId : readID(decoder.restDecoder), endAssoc)
return new ContentMove(start, end, priority)
}

View File

@@ -21,12 +21,14 @@ 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
@@ -116,6 +118,13 @@ export const splitItem = (transaction, leftItem, diff) => {
/** @type {AbstractType<any>} */ (rightItem.parent)._map.set(rightItem.parentSub, rightItem)
}
leftItem.length = diff
if (leftItem.moved) {
rightItem.moved = leftItem.moved
const m = transaction.prevMoved.get(leftItem)
if (m) {
transaction.prevMoved.set(rightItem, m)
}
}
return rightItem
}
@@ -125,12 +134,13 @@ export const splitItem = (transaction, leftItem, diff) => {
* @param {Transaction} transaction The Yjs instance.
* @param {Item} item
* @param {Set<Item>} redoitems
* @param {Array<Item>} itemsToDelete
*
* @return {Item|null}
*
* @private
*/
export const redoItem = (transaction, item, redoitems) => {
export const redoItem = (transaction, item, redoitems, itemsToDelete) => {
const doc = transaction.doc
const store = doc.store
const ownClientID = doc.clientID
@@ -170,7 +180,7 @@ export const redoItem = (transaction, item, redoitems) => {
// make sure that parent is redone
if (parentItem !== null && parentItem.deleted === true && parentItem.redone === null) {
// try to undo parent if it will be undone anyway
if (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems) === null) {
if (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete) === null) {
return null
}
}
@@ -209,6 +219,11 @@ export const redoItem = (transaction, item, redoitems) => {
}
right = right.right
}
// Iterate right while right is in itemsToDelete
// If it is intended to delete right while item is redone, we can expect that item should replace right.
while (left !== null && left.right !== null && left.right !== right && itemsToDelete.findIndex(d => d === /** @type {Item} */ (left).right) >= 0) {
left = left.right
}
}
const nextClock = getState(store, ownClientID)
const nextId = createID(ownClientID, nextClock)
@@ -275,11 +290,18 @@ export class Item extends AbstractStruct {
*/
this.parentSub = parentSub
/**
* If this type's effect is reundone this type refers to the type that undid
* If this type's effect is reundone this type refers to the type-id 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}
*/
@@ -361,11 +383,21 @@ 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, store, this.origin)
this.left = getItemCleanEnd(transaction, this.origin)
this.origin = this.left.lastId
}
if (this.rightOrigin) {
@@ -393,6 +425,7 @@ export class Item extends AbstractStruct {
this.parent = /** @type {ContentType} */ (parentItem.content).type
}
}
return null
}
@@ -403,7 +436,7 @@ export class Item extends AbstractStruct {
integrate (transaction, offset) {
if (offset > 0) {
this.id.clock += offset
this.left = getItemCleanEnd(transaction, transaction.doc.store, createID(this.id.client, this.id.clock - 1))
this.left = getItemCleanEnd(transaction, createID(this.id.client, this.id.clock - 1))
this.origin = this.left.lastId
this.content = this.content.splice(offset)
this.length -= offset
@@ -502,6 +535,24 @@ export class Item extends AbstractStruct {
if (this.parentSub === null && this.countable && !this.deleted) {
/** @type {AbstractType<any>} */ (this.parent)._length += this.length
}
// check if this item is in a moved range
if ((this.left && this.left.moved) || (this.right && this.right.moved)) {
const leftMoved = this.left && this.left.moved && /** @type {ContentMove} */ (this.left.moved.content)
const rightMoved = this.right && this.right.moved && /** @type {ContentMove} */ (this.right.moved.content)
if (leftMoved === rightMoved) {
this.moved = /** @type {Item} */ (this.left).moved
} else if (
(leftMoved != null && !leftMoved.isCollapsed()) ||
(rightMoved != null && !rightMoved.isCollapsed())
) {
// We know that this item is on the edge of a moved range.
// @todo Instead, we could check to which moved-range this item belongs
// This approach (reintegration) is pretty expensive in some scenarios
leftMoved && leftMoved.integrate(transaction, /** @type {any} */ (this.left).moved)
rightMoved && rightMoved.integrate(transaction, /** @type {any} */ (this.right).moved)
}
}
addStruct(transaction.doc.store, this)
this.content.integrate(transaction, this)
// add parent to transaction.changed
@@ -563,21 +614,22 @@ 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) {
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
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)
}
}
}
})
}
if (right.keep) {
this.keep = true
@@ -607,7 +659,23 @@ 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.content.delete(transaction, this)
}
}
/**
* Similar to `this.delete(tr)`, but additionally ensures
* that the deleted range is broadcasted using a different
* origin/source in a separate update event, so that
* the providers don't filter this message.
*
* @param {Transaction} transaction
* @param {boolean} isLocal
*/
deleteAsCleanup (transaction, isLocal) {
this.delete(transaction)
if (!isLocal) {
addToDeleteSet(transaction.cleanupDeletions, this.id.client, this.id.clock, this.length)
}
}
@@ -694,7 +762,7 @@ export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS
* @type {Array<function(UpdateDecoderV1 | UpdateDecoderV2):AbstractContent>}
*/
export const contentRefs = [
() => { error.unexpectedCase() }, // GC is not ItemContent
error.unexpectedCase, // GC is not ItemContent
readContentDeleted, // 1
readContentJSON, // 2
readContentBinary, // 3
@@ -704,7 +772,8 @@ export const contentRefs = [
readContentType, // 7
readContentAny, // 8
readContentDoc, // 9
() => { error.unexpectedCase() } // 10 - Skip is not ItemContent
error.unexpectedCase, // 10 - Skip is not ItemContent
readContentMove // 11
]
/**
@@ -771,8 +840,9 @@ export class AbstractContent {
/**
* @param {Transaction} transaction
* @param {Item} item
*/
delete (transaction) {
delete (transaction, item) {
throw error.methodUnimplemented()
}

View File

@@ -10,8 +10,8 @@ import {
createID,
ContentAny,
ContentBinary,
getItemCleanStart,
ContentDoc, YText, YArray, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
ListCursor,
ContentDoc, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map'
@@ -19,68 +19,8 @@ import * as iterator from 'lib0/iterator'
import * as error from 'lib0/error'
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
}
}
const maxSearchMarker = 300
const freshSearchMarkerDistance = 30
/**
* Search marker help us to find positions in the associative array faster.
@@ -89,82 +29,64 @@ const markPosition = (searchMarker, p, index) => {
*
* A maximum of `maxSearchMarker` objects are created.
*
* This function always returns a refreshed marker (updated timestamp)
*
* @template T
* @param {Transaction} tr
* @param {AbstractType<any>} yarray
* @param {number} index
* @param {function(ListCursor):T} f
* @return T
*/
export const findMarker = (yarray, index) => {
if (yarray._start === null || index === 0 || yarray._searchMarker === null) {
return null
export const useSearchMarker = (tr, yarray, index, f) => {
const searchMarker = yarray._searchMarker
if (searchMarker === null || yarray._start === null || index < freshSearchMarkerDistance) {
return f(new ListCursor(yarray).forward(tr, index, 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
if (searchMarker.length === 0) {
const sm = new ListCursor(yarray).forward(tr, index, true)
searchMarker.push(sm)
if (sm.nextItem) sm.nextItem.marker = true
}
// iterate to right if possible
while (p.right !== null && pindex < index) {
if (!p.deleted && p.countable) {
if (index < pindex + p.length) {
break
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) > freshSearchMarkerDistance || newIsCheaper)
const fsm = createFreshMarker ? (newIsCheaper ? new ListCursor(yarray) : sm.clone()) : sm
const prevItem = /** @type {Item} */ (sm.nextItem)
if (createFreshMarker) {
searchMarker.push(fsm)
}
pindex += p.length
}
p = p.right
}
// 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
const diff = fsm.index - index
if (diff > 0) {
fsm.backward(tr, diff)
} else {
// create new marker
return markPosition(yarray._searchMarker, p, pindex)
fsm.forward(tr, -diff, true)
}
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
}
fsm.index -= fsm.rel
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
}
/**
@@ -172,39 +94,25 @@ export const findMarker = (yarray, index) => {
*
* This should be called before doing a deletion!
*
* @param {Array<ArraySearchMarker>} searchMarker
* @param {Array<ListCursor>} searchMarker
* @param {number} index
* @param {number} len If insertion, len is positive. If deletion, len is negative.
* @param {ListCursor|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) => {
export const updateMarkerChanges = (searchMarker, index, len, origSearchMarker) => {
for (let i = searchMarker.length - 1; i >= 0; i--) {
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
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)
searchMarker.splice(i, 1)
if (marker.nextItem) marker.nextItem.marker = false
continue
}
m.p = p
p.marker = true
if (index < marker.index) { // a simple index <= m.index check would actually suffice
marker.index = math.max(index, marker.index + len)
}
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)
}
}
}
@@ -282,9 +190,16 @@ export class AbstractType {
*/
this._dEH = createEventHandler()
/**
* @type {null | Array<ArraySearchMarker>}
* @type {null | Array<ListCursor>}
*/
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()
}
/**
@@ -454,176 +369,11 @@ export const typeListToArray = type => {
return cs
}
/**
* @param {AbstractType<any>} type
* @param {Snapshot} snapshot
* @return {Array<any>}
*
* @private
* @function
*/
export const typeListToArraySnapshot = (type, snapshot) => {
const cs = []
let n = type._start
while (n !== null) {
if (n.countable && isVisible(n, snapshot)) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
cs.push(c[i])
}
}
n = n.right
}
return cs
}
/**
* Executes a provided function on once on overy element of this YArray.
*
* @param {AbstractType<any>} type
* @param {function(any,number,any):void} f A function to execute on every element of this YArray.
*
* @private
* @function
*/
export const typeListForEach = (type, f) => {
let index = 0
let n = type._start
while (n !== null) {
if (n.countable && !n.deleted) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
f(c[i], index++, type)
}
}
n = n.right
}
}
/**
* @template C,R
* @param {AbstractType<any>} type
* @param {function(C,number,AbstractType<any>):R} f
* @return {Array<R>}
*
* @private
* @function
*/
export const typeListMap = (type, f) => {
/**
* @type {Array<any>}
*/
const result = []
typeListForEach(type, (c, i) => {
result.push(f(c, i, type))
})
return result
}
/**
* @param {AbstractType<any>} type
* @return {IterableIterator<any>}
*
* @private
* @function
*/
export const typeListCreateIterator = type => {
let n = type._start
/**
* @type {Array<any>|null}
*/
let currentContent = null
let currentContentIndex = 0
return {
[Symbol.iterator] () {
return this
},
next: () => {
// find some content
if (currentContent === null) {
while (n !== null && n.deleted) {
n = n.right
}
// check if we reached the end, no need to check currentContent, because it does not exist
if (n === null) {
return {
done: true,
value: undefined
}
}
// we found n, so we can set currentContent
currentContent = n.content.getContent()
currentContentIndex = 0
n = n.right // we used the content of n, now iterate to next
}
const value = currentContent[currentContentIndex++]
// check if we need to empty currentContent
if (currentContent.length <= currentContentIndex) {
currentContent = null
}
return {
done: false,
value
}
}
}
}
/**
* Executes a provided function on once on overy element of this YArray.
* Operates on a snapshotted state of the document.
*
* @param {AbstractType<any>} type
* @param {function(any,number,AbstractType<any>):void} f A function to execute on every element of this YArray.
* @param {Snapshot} snapshot
*
* @private
* @function
*/
export const typeListForEachSnapshot = (type, f, snapshot) => {
let index = 0
let n = type._start
while (n !== null) {
if (n.countable && isVisible(n, snapshot)) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
f(c[i], index++, type)
}
}
n = n.right
}
}
/**
* @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
* @param {Item?} referenceItem
* @param {Array<Object<string,any>|Array<any>|boolean|number|string|Uint8Array>} content
* @param {Array<Object<string,any>|Array<any>|boolean|number|null|string|Uint8Array>} content
*
* @private
* @function
@@ -635,7 +385,7 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
const store = doc.store
const right = referenceItem === null ? parent._start : referenceItem.right
/**
* @type {Array<Object|Array<any>|number>}
* @type {Array<Object|Array<any>|number|null>}
*/
let jsonContent = []
const packJsonContent = () => {
@@ -646,6 +396,9 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
}
}
content.forEach(c => {
if (c === null) {
jsonContent.push(c)
} else {
switch (c.constructor) {
case Number:
case Object:
@@ -675,109 +428,11 @@ 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|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)
}
/**
* @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
@@ -797,7 +452,7 @@ export const typeMapDelete = (transaction, parent, key) => {
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {string} key
* @param {Object|number|Array<any>|string|Uint8Array|AbstractType<any>} value
* @param {Object|number|null|Array<any>|string|Uint8Array|AbstractType<any>} value
*
* @private
* @function
@@ -838,7 +493,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
/**
* @param {AbstractType<any>} parent
* @param {string} key
* @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
*
* @private
* @function
@@ -850,7 +505,7 @@ export const typeMapGet = (parent, key) => {
/**
* @param {AbstractType<any>} parent
* @return {Object<string,Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined>}
* @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined>}
*
* @private
* @function
@@ -885,7 +540,7 @@ export const typeMapHas = (parent, key) => {
* @param {AbstractType<any>} parent
* @param {string} key
* @param {Snapshot} snapshot
* @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
*
* @private
* @function

View File

@@ -5,19 +5,15 @@
import {
YEvent,
AbstractType,
typeListGet,
typeListToArray,
typeListForEach,
typeListCreateIterator,
typeListInsertGenerics,
typeListDelete,
typeListMap,
YArrayRefID,
callTypeObservers,
transact,
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
ListCursor,
useSearchMarker,
createRelativePositionFromTypeIndex,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item, // eslint-disable-line
getMinimalListViewRanges
} from '../internals.js'
import { typeListSlice } from './AbstractType.js'
/**
* Event that describes the changes on a YArray
@@ -49,7 +45,7 @@ export class YArray extends AbstractType {
*/
this._prelimContent = []
/**
* @type {Array<ArraySearchMarker>}
* @type {Array<ListCursor>}
*/
this._searchMarker = []
}
@@ -129,14 +125,85 @@ 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 => {
typeListInsertGenerics(transaction, this, index, content)
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.
*
* If the original item is to the left of $target, then the index of the item will decrement.
*
* ```js
* yarray.insert(0, [1, 2, 3])
* yarray.move(0, 3) // move "1" to index 3
* yarray.toArray() // => [2, 3, 1]
* yarray.move(2, 0) // move "1" to index 0
* yarray.toArray() // => [1, 2, 3]
* ```
*
* @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 start = createRelativePositionFromTypeIndex(this, index, 1)
const end = start.clone()
end.assoc = -1
useSearchMarker(transaction, this, target, walker => {
walker.insertMove(transaction, [{ start, end }])
})
})
} else {
const content = /** @type {Array<any>} */ (this._prelimContent).splice(index, 1)
;/** @type {Array<any>} */ (this._prelimContent).splice(target, 0, ...content)
}
}
/**
* @experimental
*
* @param {number} startIndex Inclusive move-start
* @param {number} endIndex 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 (startIndex, endIndex, target, assocStart = 1, assocEnd = -1) {
if (
(startIndex <= target && target <= endIndex) || // It doesn't make sense to move a range into the same range (it's basically a no-op).
endIndex - startIndex < 0 // require length of >= 0
) {
return
}
if (this.doc !== null) {
transact(this.doc, transaction => {
const ranges = useSearchMarker(transaction, this, startIndex, walker =>
getMinimalListViewRanges(transaction, walker, endIndex - startIndex + 1)
)
useSearchMarker(transaction, this, target, walker => {
walker.insertMove(transaction, ranges)
})
})
} else {
const content = /** @type {Array<any>} */ (this._prelimContent).splice(startIndex, endIndex - startIndex + 1)
;/** @type {Array<any>} */ (this._prelimContent).splice(target, 0, ...content)
}
}
/**
* Appends content to this YArray.
@@ -165,7 +232,9 @@ export class YArray extends AbstractType {
delete (index, length = 1) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListDelete(transaction, this, index, length)
useSearchMarker(transaction, this, index, walker =>
walker.delete(transaction, length)
)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, length)
@@ -179,7 +248,11 @@ export class YArray extends AbstractType {
* @return {T}
*/
get (index) {
return typeListGet(this, index)
return transact(/** @type {Doc} */ (this.doc), transaction =>
useSearchMarker(transaction, this, index, walker =>
walker.slice(transaction, 1)[0]
)
)
}
/**
@@ -188,7 +261,9 @@ export class YArray extends AbstractType {
* @return {Array<T>}
*/
toArray () {
return typeListToArray(this)
return transact(/** @type {Doc} */ (this.doc), tr =>
new ListCursor(this).slice(tr, this.length)
)
}
/**
@@ -199,7 +274,11 @@ export class YArray extends AbstractType {
* @return {Array<T>}
*/
slice (start = 0, end = this.length) {
return typeListSlice(this, start, end)
return transact(/** @type {Doc} */ (this.doc), transaction =>
useSearchMarker(transaction, this, start, walker =>
walker.slice(transaction, end < 0 ? this.length + end - start : end - start)
)
)
}
/**
@@ -215,13 +294,15 @@ export class YArray extends AbstractType {
* Returns an Array with the result of calling a provided function on every
* element of this YArray.
*
* @template T,M
* @template M
* @param {function(T,number,YArray<T>):M} f Function that produces an element of the new Array
* @return {Array<M>} A new array with each element being the result of the
* callback function
*/
map (f) {
return typeListMap(this, /** @type {any} */ (f))
return transact(/** @type {Doc} */ (this.doc), tr =>
new ListCursor(this).map(tr, f)
)
}
/**
@@ -230,14 +311,17 @@ 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) {
typeListForEach(this, f)
return transact(/** @type {Doc} */ (this.doc), tr =>
new ListCursor(this).forEach(tr, f)
)
}
/**
* @return {IterableIterator<T>}
*/
[Symbol.iterator] () {
return typeListCreateIterator(this)
// @todo, this could be optimized using a real iterator
return this.toArray().values()
}
/**

View File

@@ -36,11 +36,11 @@ export class YMapEvent extends YEvent {
}
/**
* @template T number|string|Object|Array|Uint8Array
* @template MapType
* A shared Map implementation.
*
* @extends AbstractType<YMapEvent<T>>
* @implements {Iterable<T>}
* @extends AbstractType<YMapEvent<MapType>>
* @implements {Iterable<MapType>}
*/
export class YMap extends AbstractType {
/**
@@ -85,7 +85,7 @@ export class YMap extends AbstractType {
}
/**
* @return {YMap<T>}
* @return {YMap<MapType>}
*/
clone () {
const map = new YMap()
@@ -108,11 +108,11 @@ export class YMap extends AbstractType {
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Object<string,T>}
* @return {Object<string,any>}
*/
toJSON () {
/**
* @type {Object<string,T>}
* @type {Object<string,MapType>}
*/
const map = {}
this._map.forEach((item, key) => {
@@ -163,11 +163,11 @@ export class YMap extends AbstractType {
/**
* Executes a provided function on once on every key-value pair.
*
* @param {function(T,string,YMap<T>):void} f A function to execute on every element of this YArray.
* @param {function(MapType,string,YMap<MapType>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
/**
* @type {Object<string,T>}
* @type {Object<string,MapType>}
*/
const map = {}
this._map.forEach((item, key) => {
@@ -179,7 +179,7 @@ export class YMap extends AbstractType {
}
/**
* @return {IterableIterator<T>}
* @return {IterableIterator<MapType>}
*/
[Symbol.iterator] () {
return this.entries()
@@ -204,7 +204,7 @@ export class YMap extends AbstractType {
* Adds or updates an element with a specified key and value.
*
* @param {string} key The key of the element to add to this YMap
* @param {T} value The value of the element to add
* @param {MapType} value The value of the element to add
*/
set (key, value) {
if (this.doc !== null) {
@@ -221,7 +221,7 @@ export class YMap extends AbstractType {
* Returns a specified element from this YMap.
*
* @param {string} key
* @return {T|undefined}
* @return {MapType|undefined}
*/
get (key) {
return /** @type {any} */ (typeMapGet(this, key))

View File

@@ -20,13 +20,15 @@ import {
splitSnapshotAffectedStructs,
iterateDeletedStructs,
iterateStructs,
findMarker,
typeMapDelete,
typeMapSet,
typeMapGet,
typeMapGetAll,
updateMarkerChanges,
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
ContentType,
useSearchMarker,
findIndexCleanStart,
ListCursor, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
} from '../internals.js'
import * as object from 'lib0/object'
@@ -62,17 +64,16 @@ export class ItemTextListPosition {
error.unexpectedCase()
}
switch (this.right.content.constructor) {
case ContentEmbed:
case ContentString:
if (!this.right.deleted) {
this.index += this.right.length
}
break
case ContentFormat:
if (!this.right.deleted) {
updateCurrentAttributes(this.currentAttributes, /** @type {ContentFormat} */ (this.right.content))
}
break
default:
if (!this.right.deleted) {
this.index += this.right.length
}
break
}
this.left = this.right
this.right = this.right.right
@@ -91,8 +92,12 @@ export class ItemTextListPosition {
const findNextPosition = (transaction, pos, count) => {
while (pos.right !== null && count > 0) {
switch (pos.right.content.constructor) {
case ContentEmbed:
case ContentString:
case ContentFormat:
if (!pos.right.deleted) {
updateCurrentAttributes(pos.currentAttributes, /** @type {ContentFormat} */ (pos.right.content))
}
break
default:
if (!pos.right.deleted) {
if (count < pos.right.length) {
// split right
@@ -102,11 +107,6 @@ const findNextPosition = (transaction, pos, count) => {
count -= pos.right.length
}
break
case ContentFormat:
if (!pos.right.deleted) {
updateCurrentAttributes(pos.currentAttributes, /** @type {ContentFormat} */ (pos.right.content))
}
break
}
pos.left = pos.right
pos.right = pos.right.right
@@ -126,10 +126,30 @@ const findNextPosition = (transaction, pos, count) => {
*/
const findPosition = (transaction, parent, index) => {
const currentAttributes = new Map()
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)
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)
})
} else {
const pos = new ItemTextListPosition(null, parent._start, 0, currentAttributes)
return findNextPosition(transaction, pos, index)
@@ -245,7 +265,7 @@ const insertAttributes = (transaction, parent, currPos, attributes) => {
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {ItemTextListPosition} currPos
* @param {string|object} text
* @param {string|object|AbstractType<any>} text
* @param {Object<string,any>} attributes
*
* @private
@@ -262,10 +282,10 @@ const insertText = (transaction, parent, currPos, text, attributes) => {
minimizeAttributeChanges(currPos, attributes)
const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes)
// insert content
const content = text.constructor === String ? new ContentString(/** @type {string} */ (text)) : new ContentEmbed(text)
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())
updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength(), null)
}
right = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content)
right.integrate(transaction, 0)
@@ -308,8 +328,7 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
}
break
}
case ContentEmbed:
case ContentString:
default:
if (length < currPos.right.length) {
getItemCleanStart(transaction, createID(currPos.right.id.client, currPos.right.id.clock + length))
}
@@ -348,7 +367,7 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
* @function
*/
const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttributes) => {
while (end && end.content.constructor !== ContentString && end.content.constructor !== ContentEmbed) {
while (end && (!end.countable || end.deleted)) {
if (!end.deleted && end.content.constructor === ContentFormat) {
updateCurrentAttributes(endAttributes, /** @type {ContentFormat} */ (end.content))
}
@@ -381,12 +400,12 @@ const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttri
*/
const cleanupContextlessFormattingGap = (transaction, item) => {
// iterate until item.right is null or content
while (item && item.right && (item.right.deleted || (item.right.content.constructor !== ContentString && item.right.content.constructor !== ContentEmbed))) {
while (item && item.right && (item.right.deleted || !item.right.countable)) {
item = item.right
}
const attrs = new Set()
// iterate back until a content item is found
while (item && (item.deleted || (item.content.constructor !== ContentString && item.content.constructor !== ContentEmbed))) {
while (item && (item.deleted || !item.countable)) {
if (!item.deleted && item.content.constructor === ContentFormat) {
const key = /** @type {ContentFormat} */ (item.content).key
if (attrs.has(key)) {
@@ -424,8 +443,7 @@ export const cleanupYTextFormatting = type => {
case ContentFormat:
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (end.content))
break
case ContentEmbed:
case ContentString:
default:
res += cleanupFormattingGap(transaction, start, end, startAttributes, currentAttributes)
startAttributes = map.copy(currentAttributes)
start = end
@@ -454,6 +472,7 @@ const deleteText = (transaction, currPos, length) => {
while (length > 0 && currPos.right !== null) {
if (currPos.right.deleted === false) {
switch (currPos.right.content.constructor) {
case ContentType:
case ContentEmbed:
case ContentString:
if (length < currPos.right.length) {
@@ -471,7 +490,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)
updateMarkerChanges(parent._searchMarker, currPos.index, -startLength + length, null)
}
return currPos
}
@@ -540,7 +559,7 @@ export class YTextEvent extends YEvent {
get changes () {
if (this._changes === null) {
/**
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert?:Array<any>|string, delete?:number, retain?:number}>}}
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert?:Array<any>|string|AbstractType<any>|object, delete?:number, retain?:number}>}}
*/
const changes = {
keys: this.keys,
@@ -557,7 +576,7 @@ export class YTextEvent extends YEvent {
* Compute the changes in the delta format.
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
*
* @type {Array<{insert?:string, delete?:number, retain?:number, attributes?: Object<string,any>}>}
* @type {Array<{insert?:string|object|AbstractType<any>, delete?:number, retain?:number, attributes?: Object<string,any>}>}
*
* @public
*/
@@ -565,7 +584,7 @@ export class YTextEvent extends YEvent {
if (this._delta === null) {
const y = /** @type {Doc} */ (this.target.doc)
/**
* @type {Array<{insert?:string, delete?:number, retain?:number, attributes?: Object<string,any>}>}
* @type {Array<{insert?:string|object|AbstractType<any>, delete?:number, retain?:number, attributes?: Object<string,any>}>}
*/
const delta = []
transact(y, transaction => {
@@ -626,12 +645,13 @@ export class YTextEvent extends YEvent {
}
while (item !== null) {
switch (item.content.constructor) {
case ContentType:
case ContentEmbed:
if (this.adds(item)) {
if (!this.deletes(item)) {
addOp()
action = 'insert'
insert = /** @type {ContentEmbed} */ (item.content).embed
insert = item.content.getContent()[0]
addOp()
}
} else if (this.deletes(item)) {
@@ -707,9 +727,9 @@ export class YTextEvent extends YEvent {
addOp()
}
if (value === null) {
attributes[key] = value
} else {
delete attributes[key]
} else {
attributes[key] = value
}
} else {
item.delete(transaction)
@@ -765,7 +785,7 @@ export class YText extends AbstractType {
*/
this._pending = string !== undefined ? [() => this.insert(0, string)] : []
/**
* @type {Array<ArraySearchMarker>}
* @type {Array<ListCursor>}
*/
this._searchMarker = []
}
@@ -1008,13 +1028,14 @@ export class YText extends AbstractType {
str += /** @type {ContentString} */ (n.content).str
break
}
case ContentType:
case ContentEmbed: {
packStr()
/**
* @type {Object<string,any>}
*/
const op = {
insert: /** @type {ContentEmbed} */ (n.content).embed
insert: n.content.getContent()[0]
}
if (currentAttributes.size > 0) {
const attrs = /** @type {Object<string,any>} */ ({})
@@ -1075,16 +1096,13 @@ export class YText extends AbstractType {
* Inserts an embed at a index.
*
* @param {number} index The index to insert the embed at.
* @param {Object} embed The Object that represents the embed.
* @param {Object | AbstractType<any>} embed The Object that represents the embed.
* @param {TextAttributes} attributes Attribute information to apply on the
* embed
*
* @public
*/
insertEmbed (index, embed, attributes = {}) {
if (embed.constructor !== Object) {
throw new Error('Embed must be an Object')
}
const y = this.doc
if (y !== null) {
transact(y, transaction => {

View File

@@ -7,7 +7,6 @@ import {
typeMapSet,
typeMapGet,
typeMapGetAll,
typeListForEach,
YXmlElementRefID,
YXmlText, ContentType, AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Snapshot, Doc, Item // eslint-disable-line
} from '../internals.js'
@@ -185,36 +184,6 @@ export class YXmlElement extends YXmlFragment {
return typeMapGetAll(this)
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks = {}, binding) {
const dom = _document.createElement(this.nodeName)
const attrs = this.getAttributes()
for (const key in attrs) {
dom.setAttribute(key, attrs[key])
}
typeListForEach(this, yxml => {
dom.appendChild(yxml.toDOM(_document, hooks, binding))
})
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.

View File

@@ -6,18 +6,15 @@ import {
YXmlEvent,
YXmlElement,
AbstractType,
typeListMap,
typeListForEach,
typeListInsertGenerics,
typeListInsertGenericsAfter,
typeListDelete,
typeListToArray,
YXmlFragmentRefID,
callTypeObservers,
transact,
typeListGet,
typeListSlice,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
useSearchMarker,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot, // eslint-disable-line
ListCursor
} from '../internals.js'
import * as error from 'lib0/error'
@@ -256,7 +253,10 @@ export class YXmlFragment extends AbstractType {
* @return {string} The string representation of all children.
*/
toString () {
return typeListMap(this, xml => xml.toString()).join('')
if (this.doc != null) {
return transact(this.doc, tr => new ListCursor(this).map(tr, xml => xml.toString()).join(''))
}
return ''
}
/**
@@ -266,32 +266,6 @@ export class YXmlFragment extends AbstractType {
return this.toString()
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks = {}, binding) {
const fragment = _document.createDocumentFragment()
if (binding !== undefined) {
binding._createAssociation(fragment, this)
}
typeListForEach(this, xmlType => {
fragment.insertBefore(xmlType.toDOM(_document, hooks, binding), null)
})
return fragment
}
/**
* Inserts new content at an index.
*
@@ -304,9 +278,11 @@ export class YXmlFragment extends AbstractType {
*/
insert (index, content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListInsertGenerics(transaction, this, index, content)
})
return transact(this.doc, transaction =>
useSearchMarker(transaction, this, index, walker =>
walker.insertArrayValue(transaction, content)
)
)
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, 0, ...content)
@@ -347,9 +323,11 @@ export class YXmlFragment extends AbstractType {
*/
delete (index, length = 1) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListDelete(transaction, this, index, length)
})
transact(/** @type {Doc} */ (this.doc), transaction =>
useSearchMarker(transaction, this, index, walker =>
walker.delete(transaction, length)
)
)
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, length)
@@ -390,7 +368,11 @@ export class YXmlFragment extends AbstractType {
* @return {YXmlElement|YXmlText}
*/
get (index) {
return typeListGet(this, index)
return transact(/** @type {Doc} */ (this.doc), transaction =>
useSearchMarker(transaction, this, index, walker =>
walker.slice(transaction, 1)[0]
)
)
}
/**

View File

@@ -17,6 +17,7 @@ import { Observable } from 'lib0/observable'
import * as random from 'lib0/random'
import * as map from 'lib0/map'
import * as array from 'lib0/array'
import * as promise from 'lib0/promise'
export const generateNewClientId = random.uint32
@@ -25,8 +26,10 @@ export const generateNewClientId = random.uint32
* @property {boolean} [DocOpts.gc=true] Disable garbage collection (default: gc=true)
* @property {function(Item):boolean} [DocOpts.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
* @property {string} [DocOpts.guid] Define a globally unique identifier for this document
* @property {string | null} [DocOpts.collectionid] Associate this document with a collection. This only plays a role if your provider has a concept of collection.
* @property {any} [DocOpts.meta] Any kind of meta information you want to associate with this document. If this is a subdocument, remote peers will store the meta information as well.
* @property {boolean} [DocOpts.autoLoad] If a subdocument, automatically load document. If this is a subdocument, remote peers will load the document as well automatically.
* @property {boolean} [DocOpts.shouldLoad] Whether the document should be synced by the provider now. This is toggled to true when you call ydoc.load()
*/
/**
@@ -37,12 +40,13 @@ export class Doc extends Observable {
/**
* @param {DocOpts} [opts] configuration
*/
constructor ({ guid = random.uuidv4(), gc = true, gcFilter = () => true, meta = null, autoLoad = false } = {}) {
constructor ({ guid = random.uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false, shouldLoad = true } = {}) {
super()
this.gc = gc
this.gcFilter = gcFilter
this.clientID = generateNewClientId()
this.guid = guid
this.collectionid = collectionid
/**
* @type {Map<string, AbstractType<YEvent>>}
*/
@@ -65,9 +69,16 @@ export class Doc extends Observable {
* @type {Item?}
*/
this._item = null
this.shouldLoad = autoLoad
this.shouldLoad = shouldLoad
this.autoLoad = autoLoad
this.meta = meta
this.isLoaded = false
this.whenLoaded = promise.create(resolve => {
this.on('load', () => {
this.isLoaded = true
resolve(this)
})
})
}
/**
@@ -194,8 +205,9 @@ export class Doc extends Observable {
}
/**
* @template T
* @param {string} [name]
* @return {YMap<any>}
* @return {YMap<T>}
*
* @public
*/
@@ -245,16 +257,12 @@ export class Doc extends Observable {
if (item !== null) {
this._item = null
const content = /** @type {ContentDoc} */ (item.content)
if (item.deleted) {
// @ts-ignore
content.doc = null
} else {
content.doc = new Doc({ guid: this.guid, ...content.opts })
content.doc = new Doc({ guid: this.guid, ...content.opts, shouldLoad: false })
content.doc._item = item
}
transact(/** @type {any} */ (item).parent.doc, transaction => {
const doc = content.doc
if (!item.deleted) {
transaction.subdocsAdded.add(content.doc)
transaction.subdocsAdded.add(doc)
}
transaction.subdocsRemoved.add(this)
}, null, true)

739
src/utils/ListCursor.js Normal file
View File

@@ -0,0 +1,739 @@
import * as error from 'lib0/error'
import {
getItemCleanStart,
createID,
getMovedCoords,
updateMarkerChanges,
getState,
ContentAny,
ContentBinary,
ContentType,
ContentDoc,
Doc,
compareIDs,
createRelativePosition,
RelativePosition, ID, AbstractContent, ContentMove, Transaction, Item, AbstractType // eslint-disable-line
} from '../internals.js'
import { compareRelativePositions } from './RelativePosition.js'
import * as array from 'lib0/array'
const lengthExceeded = error.create('Length exceeded!')
/**
* We keep the moved-stack across several transactions. Local or remote changes can invalidate
* "moved coords" on the moved-stack.
*
* The reason for this is that if assoc < 0, then getMovedCoords will return the target.right item.
* While the computed item is on the stack, it is possible that a user inserts something between target
* and the item on the stack. Then we expect that the newly inserted item is supposed to be on the new
* computed item.
*
* @param {Transaction} tr
* @param {ListCursor} li
*/
const popMovedStack = (tr, li) => {
let { start, end, move } = li.movedStack.pop() || { start: null, end: null, move: null }
if (move) {
const moveContent = /** @type {ContentMove} */ (move.content)
if (
(
moveContent.start.assoc < 0 && (
(start === null && moveContent.start.item !== null) ||
(start !== null && !compareIDs(/** @type {Item} */ (start.left).lastId, moveContent.start.item))
)
) || (
moveContent.end.assoc < 0 && (
(end === null && moveContent.end.item !== null) ||
(end !== null && !compareIDs(/** @type {Item} */ (end.left).lastId, moveContent.end.item))
)
)
) {
const coords = getMovedCoords(moveContent, tr, false)
start = coords.start
end = coords.end
}
}
li.currMove = move
li.currMoveStart = start
li.currMoveEnd = end
li.reachedEnd = false
}
/**
* Structure that helps to iterate through list-like structures. This is a useful abstraction that keeps track of move operations.
*/
export class ListCursor {
/**
* @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 ListCursor(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, true)
} else if (diff < 0) {
this.backward(tr, -diff)
}
}
/**
* When using skipUncountables=false within a "useSearchMarker" call, it is recommended
* to move the marker to the end. @todo do this after each useSearchMarkerCall
*
* @param {Transaction} tr
* @param {number} len
* @param {boolean} skipUncountables Iterate as much as possible iterating over uncountables until we find the next item.
*/
forward (tr, len, skipUncountables) {
if (len === 0 && this.nextItem == null) {
return this
}
if (this.index + len > this.type._length || this.nextItem == null) {
throw lengthExceeded
}
let item = /** @type {Item} */ (this.nextItem)
this.index += len
// eslint-disable-next-line no-unmodified-loop-condition
while ((!this.reachedEnd || this.currMove !== null) && (len > 0 || (skipUncountables && 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
popMovedStack(tr, this)
} else if (item === null) {
error.unexpectedCase() // should never happen
} 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, false)
this.currMove = item
this.currMoveStart = start
this.currMoveEnd = end
item = start
continue
}
if (this.reachedEnd) {
throw error.unexpectedCase
}
if (item.right) {
item = item.right
} else {
this.reachedEnd = true
}
}
this.index -= len
this.nextItem = item
return this
}
/**
* We prefer to insert content outside of a moved range.
* Try to escape the moved range by walking to the left over deleted items.
*
* @param {Transaction} tr
*/
reduceMoveDepth (tr) {
let nextItem = this.nextItem
if (nextItem !== null) {
while (this.currMove) {
if (nextItem === this.currMoveStart) {
nextItem = /** @type {Item} */ (this.currMove) // we iterate to the left after the current condition
popMovedStack(tr, this)
continue
}
// check if we can iterate to the left while stepping over deleted items until we find an item === this.currMoveStart
/**
* @type {Item} nextItem
*/
let item = nextItem
while (item.deleted && item.moved === this.currMove && item !== this.currMoveStart) {
item = /** @type {Item} */ (item.left) // this must exist otherwise we miscalculated the move
}
if (item === this.currMoveStart) {
// we only want to iterate over deleted items if we can escape a move
nextItem = item
} else {
break
}
}
this.nextItem = nextItem
}
}
/**
* @param {Transaction} tr
* @param {number} len
* @return {ListCursor}
*/
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
if (item && item.content.constructor === ContentMove) {
item = item.left
} else {
len += ((item && item.countable && !item.deleted && item.moved === this.currMove) ? item.length : 0) - 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, false)
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
popMovedStack(tr, this)
}
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) {
if (this.index + len > this.type._length) {
throw lengthExceeded
}
this.index += len
/**
* We store nextItem in a variable because this version cannot be null.
*/
let nextItem = /** @type {Item} */ (this.nextItem)
while (len > 0 && !this.reachedEnd) {
while (nextItem.countable && !this.reachedEnd && len > 0 && nextItem !== this.currMoveEnd) {
if (!nextItem.deleted && nextItem.moved === this.currMove) {
const slicedContent = slice(nextItem.content, this.rel, len)
len -= slicedContent.length
value = concat(value, slicedContent)
if (this.rel + slicedContent.length === nextItem.length) {
this.rel = 0
} else {
this.rel += slicedContent.length
continue // do not iterate to item.right
}
}
if (nextItem.right) {
nextItem = nextItem.right
} else {
this.reachedEnd = true
}
}
if ((!this.reachedEnd || this.currMove !== null) && len > 0) {
// always set nextItem before any method call
this.nextItem = nextItem
this.forward(tr, 0, true)
nextItem = this.nextItem
}
}
this.nextItem = nextItem
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
if (this.index + len > this.type._length) {
throw lengthExceeded
}
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, true)
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.reduceMoveDepth(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 {Array<{ start: RelativePosition, end: RelativePosition }>} ranges
*/
insertMove (tr, ranges) {
this.insertContents(tr, ranges.map(range => new ContentMove(range.start, range.end, -1)))
// @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 // @todo instead, iterate through sm and delete all marked properties on items
}
/**
* @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
}
/**
* Move-ranges must not cross each other.
*
* This function computes the minimal amount of ranges to move a range of content to
* a different place.
*
* Algorithm:
* * Store the current stack in $preStack and $preItem = walker.nextItem
* * Iterate forward $len items.
* * The current stack is stored is $afterStack and $
* * Delete the stack-items that both of them have in common
*
* @param {Transaction} tr
* @param {ListCursor} walker
* @param {number} len
* @return {Array<{ start: RelativePosition, end: RelativePosition }>}
*/
export const getMinimalListViewRanges = (tr, walker, len) => {
if (len === 0) return []
if (walker.index + len > walker.type._length) {
throw lengthExceeded
}
// stepping outside the current move-range as much as possible
walker.reduceMoveDepth(tr)
/**
* @type {Array<{ start: RelativePosition, end: RelativePosition, move: Item | null }>}
*/
const ranges = []
// store relevant information for the beginning, before we iterate forward
/**
* @type {Array<Item>}
*/
const preStack = walker.movedStack.map(si => si.move)
const preMove = walker.currMove
const preItem = /** @type {Item} */ (walker.nextItem)
const preRel = walker.rel
walker.forward(tr, len, false)
// store the same information for the end, after we iterate forward
/**
* @type {Array<Item>}
*/
const afterStack = walker.movedStack.map(si => si.move)
const afterMove = walker.currMove
/**
const nextIsCurrMoveStart = walker.nextItem === walker.currMoveStart
const afterItem = /** @type {Item} / (nextIsCurrMoveStart
? walker.currMove
: (walker.rel > 0 || walker.reachedEnd)
? walker.nextItem
: /** @type {Item} / (walker.nextItem).left
) */
const afterItem = /** @type {Item} */ (
(walker.rel > 0 || walker.reachedEnd)
? walker.nextItem
: /** @type {Item} */ (walker.nextItem).left
)
/**
* afterRel is always > 0
*/
const afterRel = walker.rel > 0
? walker.rel
: afterItem.length
walker.forward(tr, 0, false) // @todo remove once this is done is useSearchMarker
let start = createRelativePosition(walker.type, createID(preItem.id.client, preItem.id.clock + preRel), 0)
let end = createRelativePosition(
walker.type,
createID(afterItem.id.client, afterItem.id.clock + afterRel - 1),
-1
)
if (preMove) {
preStack.push(preMove)
}
if (afterMove) {
afterStack.push(afterMove)
}
// remove common stack-items
while (preStack.length > 0 && preStack[0] === afterStack[0]) {
preStack.shift()
afterStack.shift()
}
const topLevelMove = preStack.length > 0 ? preStack[0].moved : (afterStack.length > 0 ? afterStack[0].moved : null)
// remove stack-items that are useless for our computation (that wouldn't produce meaningful ranges)
// @todo
while (preStack.length > 0) {
const move = /** @type {Item} */ (preStack.pop())
ranges.push({
start,
end: /** @type {ContentMove} */ (move.content).end,
move
})
start = createRelativePosition(walker.type, createID(move.id.client, move.id.clock), -1)
}
const middleMove = { start, end, move: topLevelMove }
ranges.push(middleMove)
while (afterStack.length > 0) {
const move = /** @type {Item} */ (afterStack.pop())
ranges.push({
start: /** @type {ContentMove} */ (move.content).start,
end,
move
})
end = createRelativePosition(walker.type, createID(move.id.client, move.id.clock), 0)
}
// Update end of the center move operation
// Move ranges must be applied in order
middleMove.end = end
const normalizedRanges = array.flatten(ranges.map(range => {
// A subset of a range could be moved by another move with a higher priority.
// If that is the case, we need to ignore those moved items.
const { start, end } = getMovedCoords(range, tr, false)
const move = range.move
const ranges = []
/**
* @type {RelativePosition | null}
*/
let rangeStart = range.start
/**
* @type {Item}
*/
let item = start
while (item !== end) {
if (item.moved !== move && rangeStart != null) {
ranges.push({ start: rangeStart, end: createRelativePosition(walker.type, createID(item.id.client, item.id.clock), 0) })
rangeStart = null
}
if (item.moved === move && rangeStart === null) {
// @todo It might be better to set this to item.left, with assoc -1
rangeStart = createRelativePosition(walker.type, createID(item.id.client, item.id.clock), 0)
}
item = /** @type {Item} */ (item.right)
}
if (rangeStart != null) {
ranges.push({
start: rangeStart,
end: range.end
})
}
return ranges
}))
// filter out unnecessary ranges
return normalizedRanges.filter(range => !compareRelativePositions(range.start, range.end))
}

View File

@@ -9,6 +9,8 @@ import {
createID,
ContentType,
followRedone,
transact,
useSearchMarker,
ID, Doc, AbstractType // eslint-disable-line
} from '../internals.js'
@@ -73,6 +75,10 @@ export class RelativePosition {
*/
this.assoc = assoc
}
clone () {
return new RelativePosition(this.type, this.tname, this.item, this.assoc)
}
}
/**
@@ -161,7 +167,6 @@ 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) {
@@ -169,21 +174,17 @@ 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)
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)
}
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)
})
)
}
/**
@@ -195,7 +196,7 @@ export const createRelativePositionFromTypeIndex = (type, index, assoc = 0) => {
export const writeRelativePosition = (encoder, rpos) => {
const { type, tname, item, assoc } = rpos
if (item !== null) {
encoding.writeVarUint(encoder, 0)
encoding.writeUint8(encoder, 0)
writeID(encoder, item)
} else if (tname !== null) {
// case 2: found position at the end of the list and type is stored in y.share
@@ -232,7 +233,7 @@ export const readRelativePosition = decoder => {
let type = null
let tname = null
let itemID = null
switch (decoding.readVarUint(decoder)) {
switch (decoding.readUint8(decoder)) {
case 0:
// case 1: found position somewhere in the linked list
itemID = readID(decoder)

View File

@@ -129,9 +129,9 @@ export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc
* @protected
* @function
*/
export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : (
snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
)
export const isVisible = (item, snapshot) => snapshot === undefined
? !item.deleted
: snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
/**
* @param {Transaction} transaction

View File

@@ -199,19 +199,18 @@ 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, store, id) => {
export const getItemCleanEnd = (transaction, id) => {
/**
* @type {Array<Item>}
*/
// @ts-ignore
const structs = store.clients.get(id.client)
const structs = transaction.doc.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

@@ -14,6 +14,7 @@ import {
UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as set from 'lib0/set'
@@ -61,6 +62,13 @@ export class Transaction {
* @type {DeleteSet}
*/
this.deleteSet = new DeleteSet()
/**
* These deletes were used to cleanup the document and
* should be broadcasted again using a different transaction-origin.
*
* @type {DeleteSet}
*/
this.cleanupDeletions = new DeleteSet()
/**
* Holds the state before the transaction started.
* @type {Map<Number,Number>}
@@ -114,6 +122,14 @@ 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()
}
}
@@ -132,6 +148,18 @@ export const writeUpdateMessageFromTransaction = (encoder, transaction) => {
return true
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Transaction} transaction
*/
export const writeCleanupMessageFromTransaction = (encoder, transaction) => {
const ds = transaction.cleanupDeletions
sortAndMergeDeleteSet(ds)
// write structs: 0 structs were created
encoding.writeVarUint(encoder.restEncoder, 0)
writeDeleteSet(encoder, ds)
}
/**
* @param {Transaction} transaction
*
@@ -331,16 +359,22 @@ const cleanupTransactions = (transactionCleanups, i) => {
}
}
if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
doc.clientID = generateNewClientId()
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
doc.clientID = generateNewClientId()
}
// @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc])
const needsCleanupEvent = transaction.cleanupDeletions.clients.size > 0
if (doc._observers.has('update')) {
const encoder = new UpdateEncoderV1()
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
if (hasContent) {
doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc, transaction])
if (needsCleanupEvent) {
const encoder = new UpdateEncoderV1()
writeCleanupMessageFromTransaction(encoder, transaction)
doc.emit('update', [encoder.toUint8Array(), 'cleanup', doc, transaction])
}
}
}
if (doc._observers.has('updateV2')) {
@@ -348,13 +382,26 @@ const cleanupTransactions = (transactionCleanups, i) => {
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
if (hasContent) {
doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc, transaction])
if (needsCleanupEvent) {
const encoder = new UpdateEncoderV2()
writeCleanupMessageFromTransaction(encoder, transaction)
doc.emit('updateV2', [encoder.toUint8Array(), 'cleanup', doc, transaction])
}
}
transaction.subdocsAdded.forEach(subdoc => doc.subdocs.add(subdoc))
transaction.subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc))
doc.emit('subdocs', [{ loaded: transaction.subdocsLoaded, added: transaction.subdocsAdded, removed: transaction.subdocsRemoved }])
transaction.subdocsRemoved.forEach(subdoc => subdoc.destroy())
}
const { subdocsAdded, subdocsLoaded, subdocsRemoved } = transaction
if (subdocsAdded.size > 0 || subdocsRemoved.size > 0 || subdocsLoaded.size > 0) {
subdocsAdded.forEach(subdoc => {
subdoc.clientID = doc.clientID
if (subdoc.collectionid == null) {
subdoc.collectionid = doc.collectionid
}
doc.subdocs.add(subdoc)
})
subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc))
doc.emit('subdocs', [{ loaded: subdocsLoaded, added: subdocsAdded, removed: subdocsRemoved }, doc, transaction])
subdocsRemoved.forEach(subdoc => subdoc.destroy())
}
if (transactionCleanups.length <= i + 1) {
doc._transactionCleanups = []
@@ -369,9 +416,12 @@ const cleanupTransactions = (transactionCleanups, i) => {
/**
* Implements the functionality of `y.transact(()=>{..})`
*
* @template T
*
* @param {Doc} doc
* @param {function(Transaction):void} f
* @param {function(Transaction):T} f
* @param {any} [origin=true]
* @return {T}
*
* @function
*/
@@ -387,8 +437,9 @@ export const transact = (doc, f, origin = null, local = true) => {
}
doc.emit('beforeTransaction', [doc._transaction, doc])
}
let res
try {
f(doc._transaction)
res = f(doc._transaction)
} finally {
if (initialCall && transactionCleanups[0] === doc._transaction) {
// The first transaction ended, now process observer calls.
@@ -402,4 +453,12 @@ export const transact = (doc, f, origin = null, local = true) => {
cleanupTransactions(transactionCleanups, 0)
}
}
return res
}
/**
* @param {Transaction} tr
* @param {AbstractStruct} struct
*/
export const addsStruct = (tr, struct) =>
struct.id.clock >= (tr.beforeState.get(struct.id.client) || 0)

View File

@@ -88,7 +88,7 @@ const popStackItem = (undoManager, stack, eventType) => {
}
})
itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
performedChange = redoItem(transaction, struct, itemsToRedo, itemsToDelete) !== null || performedChange
})
// We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered.

View File

@@ -1,11 +1,13 @@
import {
isDeleted,
Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
getMovedCoords,
ContentMove, Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
} from '../internals.js'
import * as set from 'lib0/set'
import * as array from 'lib0/array'
import { addsStruct } from './Transaction.js'
/**
* YEvent describes the changes on a YType.
@@ -40,7 +42,7 @@ export class YEvent {
*/
this._keys = null
/**
* @type {null | Array<{ insert?: string | Array<any>, retain?: number, delete?: number, attributes?: Object<string, any> }>}
* @type {null | Array<{ insert?: string | Array<any> | object | AbstractType<any>, retain?: number, delete?: number, attributes?: Object<string, any> }>}
*/
this._delta = null
}
@@ -129,7 +131,7 @@ export class YEvent {
}
/**
* @type {Array<{insert?: string | Array<any>, retain?: number, delete?: number, attributes?: Object<string, any>}>}
* @type {Array<{insert?: string | Array<any> | object | AbstractType<any>, retain?: number, delete?: number, attributes?: Object<string, any>}>}
*/
get delta () {
return this.changes.delta
@@ -140,11 +142,13 @@ export class YEvent {
*
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
*
* @todo this can be removed in the next release (prefer function)
*
* @param {AbstractStruct} struct
* @return {boolean}
*/
adds (struct) {
return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0)
return addsStruct(this.transaction, struct)
}
/**
@@ -153,6 +157,7 @@ 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()
@@ -168,6 +173,26 @@ export class YEvent {
}
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, isDeleted: boolean }>}
*/
const movedStack = []
/**
* @type {Item | null}
*/
let currMove = null
/**
* @type {boolean}
*/
let currMoveIsNew = false
/**
* @type {boolean}
*/
let currMoveIsDeleted = false
/**
* @type {Item | null}
*/
let currMoveEnd = null
/**
* @type {any}
*/
@@ -177,24 +202,68 @@ export class YEvent {
delta.push(lastOp)
}
}
for (let item = target._start; item !== null; item = item.right) {
if (item.deleted) {
if (this.deletes(item) && !this.adds(item)) {
/**
* @param {Item} item
*/
const isMovedByNew = item => {
let moved = item.moved
while (moved != null) {
if (this.adds(moved)) {
return true
}
moved = moved.moved
}
return false
}
for (let item = target._start; ;) {
if (item === currMoveEnd && currMove) {
item = currMove
const { end, move, isNew, isDeleted } = movedStack.pop() || { end: null, move: null, isNew: false, isDeleted: false }
currMoveIsNew = isNew
currMoveIsDeleted = isDeleted
currMoveEnd = end
currMove = move
} else if (item === null) {
break
} else if (item.content.constructor === ContentMove) {
if (item.moved === currMove && (!item.deleted || (this.deletes(item) && !this.adds(item)))) {
movedStack.push({ end: currMoveEnd, move: currMove, isNew: currMoveIsNew, isDeleted: currMoveIsDeleted })
const { start, end } = getMovedCoords(item.content, tr, true) // We must split items for move-ranges, for single moves no splitting suffices
currMove = item
currMoveEnd = end
currMoveIsNew = this.adds(item) || currMoveIsNew
currMoveIsDeleted = item.deleted || currMoveIsDeleted
item = start
continue // do not move to item.right
}
} else if (item.moved !== currMove) {
if (!currMoveIsNew && item.countable && (!item.deleted || this.deletes(item)) && !this.adds(item) && (item.moved === null || isMovedByNew(item) || currMoveIsDeleted) && (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) && !this.transaction.prevMoved.has(item)) {
if (lastOp === null || lastOp.delete === undefined) {
packOp()
lastOp = { delete: 0 }
}
lastOp.delete += item.length
deleted.add(item)
} // else nop
}
} else {
if (this.adds(item)) {
if (currMoveIsNew || this.adds(item) || this.transaction.prevMoved.has(item)) {
if (lastOp === null || lastOp.insert === undefined) {
packOp()
lastOp = { insert: [] }
}
// @todo push items instead (or splice..)
lastOp.insert = lastOp.insert.concat(item.content.getContent())
if (!currMoveIsNew) {
added.add(item)
}
} else {
if (lastOp === null || lastOp.retain === undefined) {
packOp()
@@ -203,12 +272,14 @@ export class YEvent {
lastOp.retain += item.length
}
}
item = /** @type {Item} */ (item).right
}
if (lastOp !== null && lastOp.retain === undefined) {
if (lastOp !== null && lastOp.retain == null) {
packOp()
}
}
this._changes = changes
})
}
return /** @type {any} */ (changes)
}

View File

@@ -193,7 +193,6 @@ export const readClientsStructRefs = (decoder, doc) => {
}
}
}
// console.log('time to read: ', performance.now() - start) // @todo remove
}
return clientRefs
}
@@ -389,10 +388,6 @@ export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = n
const store = doc.store
// let start = performance.now()
const ss = readClientsStructRefs(structDecoder, doc)
// console.log('time to read structs: ', performance.now() - start) // @todo remove
// start = performance.now()
// console.log('time to merge: ', performance.now() - start) // @todo remove
// start = performance.now()
const restStructs = integrateStructs(transaction, store, ss)
const pending = store.pendingStructs
if (pending) {
@@ -416,8 +411,6 @@ export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = n
} else {
store.pendingStructs = restStructs
}
// console.log('time to integrate: ', performance.now() - start) // @todo remove
// start = performance.now()
const dsRest = readAndApplyDeleteSet(structDecoder, transaction, store)
if (store.pendingDs) {
// @todo we could make a lower-bound state-vector check as we do above
@@ -437,11 +430,6 @@ export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = n
// Either dsRest == null && pendingDs == null OR dsRest != null
store.pendingDs = dsRest
}
// console.log('time to cleanup: ', performance.now() - start) // @todo remove
// start = performance.now()
// console.log('time to resume delete readers: ', performance.now() - start) // @todo remove
// start = performance.now()
if (retry) {
const update = /** @type {{update: Uint8Array}} */ (store.pendingStructs).update
store.pendingStructs = null
@@ -601,7 +589,7 @@ export const decodeStateVector = decodedState => readStateVector(new DSDecoderV1
*/
export const writeStateVector = (encoder, sv) => {
encoding.writeVarUint(encoder.restEncoder, sv.size)
sv.forEach((clock, client) => {
Array.from(sv.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
encoding.writeVarUint(encoder.restEncoder, client) // @todo use a special client decoder that is based on mapping
encoding.writeVarUint(encoder.restEncoder, clock)
})

View File

@@ -287,6 +287,9 @@ const sliceStruct = (left, diff) => {
* @return {Uint8Array}
*/
export const mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
if (updates.length === 1) {
return updates[0]
}
const updateDecoders = updates.map(update => new YDecoder(decoding.createDecoder(update)))
let lazyStructDecoders = updateDecoders.map(decoder => new LazyStructReader(decoder, true))
@@ -305,6 +308,7 @@ 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(
@@ -312,9 +316,10 @@ export const mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = U
if (dec1.curr.id.client === dec2.curr.id.client) {
const clockDiff = dec1.curr.id.clock - dec2.curr.id.clock
if (clockDiff === 0) {
return dec1.curr.constructor === dec2.curr.constructor ? 0 : (
dec1.curr.constructor === Skip ? 1 : -1
)
// @todo remove references to skip since the structDecoders must filter Skips.
return dec1.curr.constructor === dec2.curr.constructor
? 0
: dec1.curr.constructor === Skip ? 1 : -1 // we are filtering skips anyway.
} else {
return clockDiff
}

View File

@@ -40,6 +40,7 @@ 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')
@@ -88,7 +89,7 @@ export const testSubdoc = tc => {
subdocs.get('a').load()
t.compare(event, [[], [], ['a']])
subdocs.set('b', new Y.Doc({ guid: 'a' }))
subdocs.set('b', new Y.Doc({ guid: 'a', shouldLoad: false }))
t.compare(event, [['a'], [], []])
subdocs.get('b').load()
t.compare(event, [[], [], ['a']])
@@ -124,3 +125,123 @@ export const testSubdoc = tc => {
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
}
}
/**
* @param {t.TestCase} tc
*/
export const testSubdocLoadEdgeCases = tc => {
const ydoc = new Y.Doc()
const yarray = ydoc.getArray()
const subdoc1 = new Y.Doc()
/**
* @type {any}
*/
let lastEvent = null
ydoc.on('subdocs', event => {
lastEvent = event
})
yarray.insert(0, [subdoc1])
t.assert(subdoc1.shouldLoad)
t.assert(subdoc1.autoLoad === false)
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
// destroy and check whether lastEvent adds it again to added (it shouldn't)
subdoc1.destroy()
const subdoc2 = yarray.get(0)
t.assert(subdoc1 !== subdoc2)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
// load
subdoc2.load()
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
// apply from remote
const ydoc2 = new Y.Doc()
ydoc2.on('subdocs', event => {
lastEvent = event
})
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
const subdoc3 = ydoc2.getArray().get(0)
t.assert(subdoc3.shouldLoad === false)
t.assert(subdoc3.autoLoad === false)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc3))
// load
subdoc3.load()
t.assert(subdoc3.shouldLoad)
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc3))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
}
/**
* @param {t.TestCase} tc
*/
export const testSubdocLoadEdgeCasesAutoload = tc => {
const ydoc = new Y.Doc()
const yarray = ydoc.getArray()
const subdoc1 = new Y.Doc({ autoLoad: true })
/**
* @type {any}
*/
let lastEvent = null
ydoc.on('subdocs', event => {
lastEvent = event
})
yarray.insert(0, [subdoc1])
t.assert(subdoc1.shouldLoad)
t.assert(subdoc1.autoLoad)
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
// destroy and check whether lastEvent adds it again to added (it shouldn't)
subdoc1.destroy()
const subdoc2 = yarray.get(0)
t.assert(subdoc1 !== subdoc2)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
// load
subdoc2.load()
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
// apply from remote
const ydoc2 = new Y.Doc()
ydoc2.on('subdocs', event => {
lastEvent = event
})
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
const subdoc3 = ydoc2.getArray().get(0)
t.assert(subdoc1.shouldLoad)
t.assert(subdoc1.autoLoad)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
}
/**
* @param {t.TestCase} tc
*/
export const testSubdocsUndo = tc => {
const ydoc = new Y.Doc()
const elems = ydoc.getXmlFragment()
const undoManager = new Y.UndoManager(elems)
const subdoc = new Y.Doc()
// @ts-ignore
elems.insert(0, [subdoc])
undoManager.undo()
undoManager.redo()
t.assert(elems.length === 1)
}
/**
* @param {t.TestCase} tc
*/
export const testLoadDocs = async tc => {
const ydoc = new Y.Doc()
t.assert(ydoc.isLoaded === false)
let loadedEvent = false
ydoc.on('load', () => {
loadedEvent = true
})
ydoc.emit('load', [ydoc])
await ydoc.whenLoaded
t.assert(loadedEvent)
t.assert(ydoc.isLoaded)
}

View File

@@ -12,6 +12,7 @@ import {
readContentFormat,
readContentAny,
readContentDoc,
readContentMove,
Doc,
PermanentUserData,
encodeStateAsUpdate,
@@ -24,7 +25,8 @@ import * as Y from '../src/index.js'
* @param {t.TestCase} tc
*/
export const testStructReferences = tc => {
t.assert(contentRefs.length === 11)
t.assert(contentRefs.length === 12)
// contentRefs[0] is reserved for GC
t.assert(contentRefs[1] === readContentDeleted)
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
t.assert(contentRefs[3] === readContentBinary)
@@ -35,6 +37,7 @@ 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)
}
/**
@@ -74,7 +77,7 @@ export const testDiffStateVectorOfUpdateIsEmpty = tc => {
/**
* @type {null | Uint8Array}
*/
let sv = /* any */ (null)
let sv = /** @type {any} */ (null)
ydoc.getText().insert(0, 'a')
ydoc.on('update', update => {
sv = Y.encodeStateVectorFromUpdate(update)

View File

@@ -10,6 +10,7 @@ import * as doc from './doc.tests.js'
import * as snapshot from './snapshot.tests.js'
import * as updates from './updates.tests.js'
import * as relativePositions from './relativePositions.tests.js'
import * as Y from './testHelper.js'
import { runTests } from 'lib0/testing'
import { isBrowser, isNode } from 'lib0/environment'
@@ -17,6 +18,8 @@ import * as log from 'lib0/logging'
if (isBrowser) {
log.createVConsole(document.body)
// @ts-ignore
window.Y = Y
}
runTests({
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions

View File

@@ -1,9 +1,9 @@
import * as Y from '../src/internals'
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
/**
* @param {Y.YText} ytext
* @param {Y.Text} ytext
*/
const checkRelativePositions = ytext => {
// test if all positions are encoded and restored correctly

View File

@@ -1,17 +1,17 @@
import { createDocFromSnapshot, Doc, snapshot, YMap } from '../src/internals'
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import { init } from './testHelper'
import { init } from './testHelper.js'
/**
* @param {t.TestCase} tc
*/
export const testBasicRestoreSnapshot = tc => {
const doc = new Doc({ gc: false })
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['hello'])
const snap = snapshot(doc)
const snap = Y.snapshot(doc)
doc.getArray('array').insert(1, ['world'])
const docRestored = createDocFromSnapshot(doc, snap)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), ['hello'])
t.compare(doc.getArray('array').toArray(), ['hello', 'world'])
@@ -21,19 +21,19 @@ export const testBasicRestoreSnapshot = tc => {
* @param {t.TestCase} tc
*/
export const testEmptyRestoreSnapshot = tc => {
const doc = new Doc({ gc: false })
const snap = snapshot(doc)
const doc = new Y.Doc({ gc: false })
const snap = Y.snapshot(doc)
snap.sv.set(9999, 0)
doc.getArray().insert(0, ['world'])
const docRestored = createDocFromSnapshot(doc, snap)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray().toArray(), [])
t.compare(doc.getArray().toArray(), ['world'])
// now this snapshot reflects the latest state. It shoult still work.
const snap2 = snapshot(doc)
const docRestored2 = createDocFromSnapshot(doc, snap2)
const snap2 = Y.snapshot(doc)
const docRestored2 = Y.createDocFromSnapshot(doc, snap2)
t.compare(docRestored2.getArray().toArray(), ['world'])
}
@@ -41,15 +41,15 @@ export const testEmptyRestoreSnapshot = tc => {
* @param {t.TestCase} tc
*/
export const testRestoreSnapshotWithSubType = tc => {
const doc = new Doc({ gc: false })
doc.getArray('array').insert(0, [new YMap()])
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, [new Y.Map()])
const subMap = doc.getArray('array').get(0)
subMap.set('key1', 'value1')
const snap = snapshot(doc)
const snap = Y.snapshot(doc)
subMap.set('key2', 'value2')
const docRestored = createDocFromSnapshot(doc, snap)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toJSON(), [{
key1: 'value1'
@@ -64,13 +64,13 @@ export const testRestoreSnapshotWithSubType = tc => {
* @param {t.TestCase} tc
*/
export const testRestoreDeletedItem1 = tc => {
const doc = new Doc({ gc: false })
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1', 'item2'])
const snap = snapshot(doc)
const snap = Y.snapshot(doc)
doc.getArray('array').delete(0)
const docRestored = createDocFromSnapshot(doc, snap)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item2'])
t.compare(doc.getArray('array').toArray(), ['item2'])
@@ -80,15 +80,15 @@ export const testRestoreDeletedItem1 = tc => {
* @param {t.TestCase} tc
*/
export const testRestoreLeftItem = tc => {
const doc = new Doc({ gc: false })
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1'])
doc.getMap('map').set('test', 1)
doc.getArray('array').insert(0, ['item0'])
const snap = snapshot(doc)
const snap = Y.snapshot(doc)
doc.getArray('array').delete(1)
const docRestored = createDocFromSnapshot(doc, snap)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), ['item0', 'item1'])
t.compare(doc.getArray('array').toArray(), ['item0'])
@@ -98,13 +98,13 @@ export const testRestoreLeftItem = tc => {
* @param {t.TestCase} tc
*/
export const testDeletedItemsBase = tc => {
const doc = new Doc({ gc: false })
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1'])
doc.getArray('array').delete(0)
const snap = snapshot(doc)
const snap = Y.snapshot(doc)
doc.getArray('array').insert(0, ['item0'])
const docRestored = createDocFromSnapshot(doc, snap)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), [])
t.compare(doc.getArray('array').toArray(), ['item0'])
@@ -114,13 +114,13 @@ export const testDeletedItemsBase = tc => {
* @param {t.TestCase} tc
*/
export const testDeletedItems2 = tc => {
const doc = new Doc({ gc: false })
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1', 'item2', 'item3'])
doc.getArray('array').delete(1)
const snap = snapshot(doc)
const snap = Y.snapshot(doc)
doc.getArray('array').insert(0, ['item0'])
const docRestored = createDocFromSnapshot(doc, snap)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray('array').toArray(), ['item1', 'item3'])
t.compare(doc.getArray('array').toArray(), ['item0', 'item1', 'item3'])
@@ -140,11 +140,11 @@ export const testDependentChanges = tc => {
}
/**
* @type Doc
* @type {Y.Doc}
*/
const doc0 = array0.doc
/**
* @type Doc
* @type {Y.Doc}
*/
const doc1 = array1.doc
@@ -156,16 +156,16 @@ export const testDependentChanges = tc => {
array1.insert(1, ['user2item1'])
testConnector.syncAll()
const snap = snapshot(array0.doc)
const snap = Y.snapshot(array0.doc)
array0.insert(2, ['user1item2'])
testConnector.syncAll()
array1.insert(3, ['user2item2'])
testConnector.syncAll()
const docRestored0 = createDocFromSnapshot(array0.doc, snap)
const docRestored0 = Y.createDocFromSnapshot(array0.doc, snap)
t.compare(docRestored0.getArray('array').toArray(), ['user1item1', 'user2item1'])
const docRestored1 = createDocFromSnapshot(array1.doc, snap)
const docRestored1 = Y.createDocFromSnapshot(array1.doc, snap)
t.compare(docRestored1.getArray('array').toArray(), ['user1item1', 'user2item1'])
}

View File

@@ -3,10 +3,10 @@ import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as syncProtocol from 'y-protocols/sync.js'
import * as syncProtocol from 'y-protocols/sync'
import * as object from 'lib0/object'
import * as Y from '../src/internals.js'
export * from '../src/internals.js'
import * as Y from '../src/index.js'
export * from '../src/index.js'
if (typeof window !== 'undefined') {
// @ts-ignore
@@ -279,7 +279,7 @@ export class TestConnector {
* @param {t.TestCase} tc
* @param {{users?:number}} conf
* @param {InitTestObjectCallback<T>} [initTestObject]
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.YArray<any>,array1:Y.YArray<any>,array2:Y.YArray<any>,map0:Y.YMap<any>,map1:Y.YMap<any>,map2:Y.YMap<any>,map3:Y.YMap<any>,text0:Y.YText,text1:Y.YText,text2:Y.YText,xml0:Y.YXmlElement,xml1:Y.YXmlElement,xml2:Y.YXmlElement}}
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}}
*/
export const init = (tc, { users = 5 } = {}, initTestObject) => {
/**
@@ -304,7 +304,7 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => {
result.users.push(y)
result['array' + i] = y.getArray('array')
result['map' + i] = y.getMap('map')
result['xml' + i] = y.get('xml', Y.YXmlElement)
result['xml' + i] = y.get('xml', Y.XmlElement)
result['text' + i] = y.getText('text')
}
testConnector.syncAll()
@@ -324,7 +324,7 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => {
*/
export const compare = users => {
users.forEach(u => u.connect())
while (users[0].tc.flushAllMessages()) {}
while (users[0].tc.flushAllMessages()) {} // eslint-disable-line
// For each document, merge all received document updates with Y.mergeUpdates and create a new document which will be added to the list of "users"
// This ensures that mergeUpdates works correctly
const mergedDocs = users.map(user => {
@@ -335,7 +335,7 @@ export const compare = users => {
users.push(.../** @type {any} */(mergedDocs))
const userArrayValues = users.map(u => u.getArray('array').toJSON())
const userMapValues = users.map(u => u.getMap('map').toJSON())
const userXmlValues = users.map(u => u.get('xml', Y.YXmlElement).toString())
const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString())
const userTextValues = users.map(u => u.getText('text').toDelta())
for (const u of users) {
t.assert(u.store.pendingDs === null)
@@ -362,8 +362,15 @@ export const compare = users => {
t.compare(userMapValues[i], userMapValues[i + 1])
t.compare(userXmlValues[i], userXmlValues[i + 1])
t.compare(userTextValues[i].map(/** @param {any} a */ a => typeof a.insert === 'string' ? a.insert : ' ').join('').length, users[i].getText('text').length)
t.compare(userTextValues[i], userTextValues[i + 1])
t.compare(Y.getStateVector(users[i].store), Y.getStateVector(users[i + 1].store))
t.compare(userTextValues[i], userTextValues[i + 1], '', (constructor, a, b) => {
if (a instanceof Y.AbstractType) {
t.compare(a.toJSON(), b.toJSON())
} else if (a !== b) {
t.fail('Deltas dont match')
}
return true
})
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)
}
@@ -378,8 +385,8 @@ export const compare = users => {
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
/**
* @param {Y.StructStore} ss1
* @param {Y.StructStore} ss2
* @param {import('../src/internals').StructStore} ss1
* @param {import('../src/internals').StructStore} ss2
*/
export const compareStructStores = (ss1, ss2) => {
t.assert(ss1.clients.size === ss2.clients.size)
@@ -421,13 +428,13 @@ export const compareStructStores = (ss1, ss2) => {
}
/**
* @param {Y.DeleteSet} ds1
* @param {Y.DeleteSet} ds2
* @param {import('../src/internals').DeleteSet} ds1
* @param {import('../src/internals').DeleteSet} ds2
*/
export const compareDS = (ds1, ds2) => {
t.assert(ds1.clients.size === ds2.clients.size)
ds1.clients.forEach((deleteItems1, client) => {
const deleteItems2 = /** @type {Array<Y.DeleteItem>} */ (ds2.clients.get(client))
const deleteItems2 = /** @type {Array<import('../src/internals').DeleteItem>} */ (ds2.clients.get(client))
t.assert(deleteItems2 !== undefined && deleteItems1.length === deleteItems2.length)
for (let i = 0; i < deleteItems1.length; i++) {
const di1 = deleteItems1[i]

View File

@@ -1,8 +1,4 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import {
UndoManager
} from '../src/internals.js'
import { init, compare, applyRandomTests, Doc, UndoManager } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
@@ -12,7 +8,7 @@ import * as t from 'lib0/testing'
*/
export const testUndoText = tc => {
const { testConnector, text0, text1 } = init(tc, { users: 3 })
const undoManager = new UndoManager(text0)
const undoManager = new Y.UndoManager(text0)
// items that are added & deleted in the same transaction won't be undo
text0.insert(0, 'test')
@@ -81,7 +77,7 @@ export const testDoubleUndo = tc => {
export const testUndoMap = tc => {
const { testConnector, map0, map1 } = init(tc, { users: 2 })
map0.set('a', 0)
const undoManager = new UndoManager(map0)
const undoManager = new Y.UndoManager(map0)
map0.set('a', 1)
undoManager.undo()
t.assert(map0.get('a') === 0)
@@ -120,7 +116,7 @@ export const testUndoMap = tc => {
*/
export const testUndoArray = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 3 })
const undoManager = new UndoManager(array0)
const undoManager = new Y.UndoManager(array0)
array0.insert(0, [1, 2, 3])
array1.insert(0, [4, 5, 6])
testConnector.syncAll()
@@ -171,7 +167,7 @@ export const testUndoArray = tc => {
*/
export const testUndoXml = tc => {
const { xml0 } = init(tc, { users: 3 })
const undoManager = new UndoManager(xml0)
const undoManager = new Y.UndoManager(xml0)
const child = new Y.XmlElement('p')
xml0.insert(0, [child])
const textchild = new Y.XmlText('content')
@@ -196,7 +192,7 @@ export const testUndoXml = tc => {
*/
export const testUndoEvents = tc => {
const { text0 } = init(tc, { users: 3 })
const undoManager = new UndoManager(text0)
const undoManager = new Y.UndoManager(text0)
let counter = 0
let receivedMetadata = -1
undoManager.on('stack-item-added', /** @param {any} event */ event => {
@@ -222,7 +218,7 @@ export const testUndoEvents = tc => {
export const testTrackClass = tc => {
const { users, text0 } = init(tc, { users: 3 })
// only track origins that are numbers
const undoManager = new UndoManager(text0, { trackedOrigins: new Set([Number]) })
const undoManager = new Y.UndoManager(text0, { trackedOrigins: new Set([Number]) })
users[0].transact(() => {
text0.insert(0, 'abc')
}, 42)
@@ -240,8 +236,8 @@ export const testTypeScope = tc => {
const text0 = new Y.Text()
const text1 = new Y.Text()
array0.insert(0, [text0, text1])
const undoManager = new UndoManager(text0)
const undoManagerBoth = new UndoManager([text0, text1])
const undoManager = new Y.UndoManager(text0)
const undoManagerBoth = new Y.UndoManager([text0, text1])
text1.insert(0, 'abc')
t.assert(undoManager.undoStack.length === 0)
t.assert(undoManagerBoth.undoStack.length === 1)
@@ -252,15 +248,35 @@ export const testTypeScope = tc => {
t.assert(text1.toString() === '')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoInEmbed = tc => {
const { text0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(text0)
const nestedText = new Y.Text('initial text')
undoManager.stopCapturing()
text0.insertEmbed(0, nestedText, { bold: true })
t.assert(nestedText.toString() === 'initial text')
undoManager.stopCapturing()
nestedText.delete(0, nestedText.length)
nestedText.insert(0, 'other text')
t.assert(nestedText.toString() === 'other text')
undoManager.undo()
t.assert(nestedText.toString() === 'initial text')
undoManager.undo()
t.assert(text0.length === 0)
}
/**
* @param {t.TestCase} 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 UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
const undoManager = new Y.UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
const map0 = new Y.Map()
map0.set('hi', 1)
const map1 = new Y.Map()
@@ -301,3 +317,58 @@ export const testUndoUntilChangePerformed = tc => {
undoManager.undo()
t.compareStrings(yMap2.get('key'), 'value')
}
/**
* This issue has been reported in https://github.com/yjs/yjs/issues/317
* @param {t.TestCase} tc
*/
export const testUndoNestedUndoIssue = tc => {
const doc = new Y.Doc({ gc: false })
const design = doc.getMap()
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
/**
* @type {Y.Map<any>}
*/
const text = new Y.Map()
const blocks1 = new Y.Array()
const blocks1block = new Y.Map()
doc.transact(() => {
blocks1block.set('text', 'Type Something')
blocks1.push([blocks1block])
text.set('blocks', blocks1block)
design.set('text', text)
})
const blocks2 = new Y.Array()
const blocks2block = new Y.Map()
doc.transact(() => {
blocks2block.set('text', 'Something')
blocks2.push([blocks2block])
text.set('blocks', blocks2block)
})
const blocks3 = new Y.Array()
const blocks3block = new Y.Map()
doc.transact(() => {
blocks3block.set('text', 'Something Else')
blocks3.push([blocks3block])
text.set('blocks', blocks3block)
})
t.compare(design.toJSON(), { text: { blocks: { text: 'Something Else' } } })
undoManager.undo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Something' } } })
undoManager.undo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Type Something' } } })
undoManager.undo()
t.compare(design.toJSON(), { })
undoManager.redo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Type Something' } } })
undoManager.redo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Something' } } })
undoManager.redo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Something Else' } } })
}

View File

@@ -1,4 +1,4 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import { init, compare, applyRandomTests, Doc, AbstractType, TestConnector, Item } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
@@ -432,6 +432,192 @@ 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
*/
export const testMoveSingleItemRemovesPrev = tc => {
const ydoc = new Y.Doc()
const yarray = ydoc.getArray()
yarray.insert(0, [1, 2, 3])
yarray.move(0, 3)
t.compareArrays(yarray.toArray(), [2, 3, 1])
yarray.move(2, 0)
t.compareArrays(yarray.toArray(), [1, 2, 3])
let item = yarray._start
const items = []
while (item) {
items.push(item)
item = item.right
}
t.assert(items.length === 4)
t.assert(items.filter(item => !item.deleted).length === 3)
}
/**
* Check that the searchMarker is reused correctly.
*
* @param {t.TestCase} tc
*/
export const testListWalkerReusesSearchMarker = tc => {
const ydoc = new Y.Doc()
const yarray = ydoc.getArray()
const iterations = 100
for (let i = 0; i < iterations; i++) {
yarray.insert(0, [i])
}
/**
* @type {any}
*/
let prevSm = null
for (let i = 0; i < iterations; i++) {
const v = yarray.get(i)
t.assert(v === iterations - i - 1)
t.assert(yarray._searchMarker.length <= 1)
const sm = yarray._searchMarker[0]
t.assert(prevSm == null || sm === prevSm)
prevSm = sm
}
}
/**
* @param {t.TestCase} tc
*/
export const testMoveDeletions = tc => {
const ydoc = new Y.Doc()
const yarray = ydoc.getArray()
const array = yarray.toArray()
/**
* @type {any}
*/
let lastDelta = []
yarray.observe(event => {
lastDelta = event.delta
let pos = 0
for (let i = 0; i < lastDelta.length; i++) {
const d = lastDelta[i]
if (d.retain != null) {
pos += d.retain
} else if (d.insert instanceof Array) {
array.splice(pos, 0, ...d.insert)
pos += d.insert.length
} else if (d.delete != null) {
array.splice(pos, d.delete)
}
}
})
yarray.insert(0, [1, 2, 3])
// @todo should be old-position to new-position. so that below move matches
yarray.move(2, 0)
t.compare(lastDelta, [{ insert: [3] }, { retain: 2 }, { delete: 1 }])
t.compareArrays(yarray.toArray(), [3, 1, 2])
t.compareArrays(yarray.toArray(), array)
ydoc.transact(tr => {
/** @type {Item} */ (yarray._start).delete(tr)
})
t.compare(lastDelta, [{ delete: 1 }, { retain: 2 }, { insert: [3] }])
t.compareArrays(yarray.toArray(), [1, 2, 3])
t.compareArrays(yarray.toArray(), array)
}
/**
* @todo
* @param {t.TestCase} tc
*
export const testMoveCircles = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 3 })
array0.insert(0, [1, 2, 3, 4])
testConnector.flushAllMessages()
array0.moveRange(0, 1, 3)
t.compare(array0.toArray(), [3, 1, 2, 4])
array1.moveRange(2, 3, 1)
t.compare(array1.toArray(), [1, 3, 4, 2])
testConnector.flushAllMessages()
t.assert(array0.length === 4)
t.assert(array0.length === array0.toArray().length)
t.compareArrays(array0.toArray(), array1.toArray())
}
*/
/**
* @param {t.TestCase} tc
*/
@@ -458,6 +644,23 @@ const getUniqueNumber = () => _uniqueNumber++
* @type {Array<function(Doc,prng.PRNG,any):void>}
*/
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 len = 1 // prng.int32(gen, 1, math.min(3, yarray.length - pos))
const _newPosAdj = prng.int32(gen, 0, yarray.length - len)
// make sure that we don't insert in-between the moved range
const newPos = _newPosAdj + (_newPosAdj > pos ? len : 0)
const oldContent = yarray.toArray()
// yarray.moveRange(pos, pos + len - 1, newPos)
yarray.move(pos, newPos)
const movedValues = oldContent.splice(pos, len)
oldContent.splice(pos < newPos ? newPos - len : newPos, 0, ...movedValues)
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()
@@ -488,6 +691,11 @@ const arrayTransactions = [
map.set('someprop', 43)
map.set('someprop', 44)
},
function insertTypeNull (user, gen) {
const yarray = user.getArray('array')
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [null])
},
function _delete (user, gen) {
const yarray = user.getArray('array')
const length = yarray.length
@@ -496,7 +704,7 @@ const arrayTransactions = [
let delLength = prng.int32(gen, 1, math.min(2, length - somePos))
if (prng.bool(gen)) {
const type = yarray.get(somePos)
if (type.length > 0) {
if (type instanceof Y.Array && type.length > 0) {
somePos = prng.int32(gen, 0, type.length - 1)
delLength = prng.int32(gen, 0, math.min(2, type.length - somePos))
type.delete(somePos, delLength)
@@ -511,95 +719,156 @@ 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)
}
}
t.compare(arr, yarr.toArray())
})
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())
t.compareArrays(arrs[i], Array.from(type))
}
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests6 = tc => {
applyRandomTests(tc, arrayTransactions, 6)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 6, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests10 = tc => {
compareTestobjects(applyRandomTests(tc, arrayTransactions, 10, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests30 = tc => {
compareTestobjects(applyRandomTests(tc, arrayTransactions, 30, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests35 = tc => {
compareTestobjects(applyRandomTests(tc, arrayTransactions, 35, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests40 = tc => {
applyRandomTests(tc, arrayTransactions, 40)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 40, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests42 = tc => {
applyRandomTests(tc, arrayTransactions, 42)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 42, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests43 = tc => {
applyRandomTests(tc, arrayTransactions, 43)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 43, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests44 = tc => {
applyRandomTests(tc, arrayTransactions, 44)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 44, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests45 = tc => {
applyRandomTests(tc, arrayTransactions, 45)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 45, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests46 = tc => {
applyRandomTests(tc, arrayTransactions, 46)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 46, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests300 = tc => {
applyRandomTests(tc, arrayTransactions, 300)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 300, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests400 = tc => {
applyRandomTests(tc, arrayTransactions, 400)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 400, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests500 = tc => {
applyRandomTests(tc, arrayTransactions, 500)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 500, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests600 = tc => {
applyRandomTests(tc, arrayTransactions, 600)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 600, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests1000 = tc => {
applyRandomTests(tc, arrayTransactions, 1000)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 1000, monitorArrayTestObject))
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests1800 = tc => {
applyRandomTests(tc, arrayTransactions, 1800)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 1800, monitorArrayTestObject))
}
/**
@@ -607,7 +876,7 @@ export const testRepeatGeneratingYarrayTests1800 = tc => {
*/
export const testRepeatGeneratingYarrayTests3000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, arrayTransactions, 3000)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 3000, monitorArrayTestObject))
}
/**
@@ -615,7 +884,7 @@ export const testRepeatGeneratingYarrayTests3000 = tc => {
*/
export const testRepeatGeneratingYarrayTests5000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, arrayTransactions, 5000)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 5000, monitorArrayTestObject))
}
/**
@@ -623,5 +892,5 @@ export const testRepeatGeneratingYarrayTests5000 = tc => {
*/
export const testRepeatGeneratingYarrayTests30000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, arrayTransactions, 30000)
compareTestobjects(applyRandomTests(tc, arrayTransactions, 30000, monitorArrayTestObject))
}

View File

@@ -42,6 +42,7 @@ export const testBasicMapTests = tc => {
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
users[2].disconnect()
map0.set('null', null)
map0.set('number', 1)
map0.set('string', 'hello Y')
map0.set('object', { key: { key2: 'value' } })
@@ -54,26 +55,29 @@ export const testBasicMapTests = tc => {
array.insert(0, [0])
array.insert(0, [-1])
t.assert(map0.get('null') === null, 'client 0 computed the change (null)')
t.assert(map0.get('number') === 1, 'client 0 computed the change (number)')
t.assert(map0.get('string') === 'hello Y', 'client 0 computed the change (string)')
t.assert(map0.get('boolean0') === false, 'client 0 computed the change (boolean)')
t.assert(map0.get('boolean1') === true, 'client 0 computed the change (boolean)')
t.compare(map0.get('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
t.assert(map0.get('y-map').get('y-array').get(0) === -1, 'client 0 computed the change (type)')
t.assert(map0.size === 6, 'client 0 map has correct size')
t.assert(map0.size === 7, 'client 0 map has correct size')
users[2].connect()
testConnector.flushAllMessages()
t.assert(map1.get('null') === null, 'client 1 received the update (null)')
t.assert(map1.get('number') === 1, 'client 1 received the update (number)')
t.assert(map1.get('string') === 'hello Y', 'client 1 received the update (string)')
t.assert(map1.get('boolean0') === false, 'client 1 computed the change (boolean)')
t.assert(map1.get('boolean1') === true, 'client 1 computed the change (boolean)')
t.compare(map1.get('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
t.assert(map1.get('y-map').get('y-array').get(0) === -1, 'client 1 received the update (type)')
t.assert(map1.size === 6, 'client 1 map has correct size')
t.assert(map1.size === 7, 'client 1 map has correct size')
// compare disconnected user
t.assert(map2.get('null') === null, 'client 2 received the update (null) - was disconnected')
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
t.assert(map2.get('string') === 'hello Y', 'client 2 received the update (string) - was disconnected')
t.assert(map2.get('boolean0') === false, 'client 2 computed the change (boolean)')

View File

@@ -151,6 +151,29 @@ export const testGetDeltaWithEmbeds = tc => {
}])
}
/**
* @param {t.TestCase} tc
*/
export const testTypesAsEmbed = tc => {
const { text0, text1, testConnector } = init(tc, { users: 2 })
text0.applyDelta([{
insert: new Y.Map([['key', 'val']])
}])
t.compare(text0.toDelta()[0].insert.toJSON(), { key: 'val' })
let firedEvent = false
text1.observe(event => {
const d = event.delta
t.assert(d.length === 1)
t.compare(d.map(x => /** @type {Y.AbstractType<any>} */ (x.insert).toJSON()), [{ key: 'val' }])
firedEvent = true
})
testConnector.flushAllMessages()
const delta = text1.toDelta()
t.assert(delta.length === 1)
t.compare(delta[0].insert.toJSON(), { key: 'val' })
t.assert(firedEvent, 'fired the event observer containing a Type-Embed')
}
/**
* @param {t.TestCase} tc
*/
@@ -265,10 +288,46 @@ export const testFormattingRemovedInMidText = tc => {
t.assert(Y.getTypeChildren(text0).length === 3)
}
/**
* Reported in https://github.com/yjs/yjs/issues/344
*
* @param {t.TestCase} tc
*/
export const testFormattingDeltaUnnecessaryAttributeChange = tc => {
const { text0, text1, testConnector } = init(tc, { users: 2 })
text0.insert(0, '\n', {
PARAGRAPH_STYLES: 'normal',
LIST_STYLES: 'bullet'
})
text0.insert(1, 'abc', {
PARAGRAPH_STYLES: 'normal'
})
testConnector.flushAllMessages()
/**
* @type {Array<any>}
*/
const deltas = []
text0.observe(event => {
deltas.push(event.delta)
})
text1.observe(event => {
deltas.push(event.delta)
})
text1.format(0, 1, { LIST_STYLES: 'number' })
testConnector.flushAllMessages()
const filteredDeltas = deltas.filter(d => d.length > 0)
t.assert(filteredDeltas.length === 2)
t.compare(filteredDeltas[0], [
{ retain: 1, attributes: { LIST_STYLES: 'number' } }
])
t.compare(filteredDeltas[0], filteredDeltas[1])
}
/**
* @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
@@ -493,6 +552,28 @@ export const testSearchMarkerBug1 = tc => {
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testFormattingBug = async tc => {
const ydoc1 = new Y.Doc()
const ydoc2 = new Y.Doc()
const text1 = ydoc1.getText()
text1.insert(0, '\n\n\n')
text1.format(0, 3, { url: 'http://example.com' })
ydoc1.getText().format(1, 1, { url: 'http://docs.yjs.dev' })
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc1))
const text2 = ydoc2.getText()
const expectedResult = [
{ insert: '\n', attributes: { url: 'http://example.com' } },
{ insert: '\n', attributes: { url: 'http://docs.yjs.dev' } },
{ insert: '\n', attributes: { url: 'http://example.com' } }
]
t.compare(text1.toDelta(), expectedResult)
t.compare(text1.toDelta(), text2.toDelta())
console.log(text1.toDelta())
}
// RANDOM TESTS
let charCounter = 0
@@ -628,7 +709,11 @@ const qChanges = [
(y, gen) => { // insert embed
const ytext = y.getText('text')
const insertPos = prng.int32(gen, 0, ytext.length)
if (prng.bool(gen)) {
ytext.insertEmbed(insertPos, { image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' })
} else {
ytext.insertEmbed(insertPos, new Y.Map([[prng.word(gen), prng.word(gen)]]))
}
},
/**
* @param {Y.Doc} y
@@ -675,8 +760,12 @@ const qChanges = [
*/
const checkResult = result => {
for (let i = 1; i < result.testObjects.length; i++) {
const p1 = result.users[i].getText('text').toDelta()
const p2 = result.users[i].getText('text').toDelta()
/**
* @param {any} d
*/
const typeToObject = d => d.insert instanceof Y.AbstractType ? d.insert.toJSON() : d
const p1 = result.users[i].getText('text').toDelta().map(typeToObject)
const p2 = result.users[i].getText('text').toDelta().map(typeToObject)
t.compare(p1, p2)
}
// Uncomment this to find formatting-cleanup issues