Compare commits
172 Commits
v13.0.0-88
...
v13.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fb63de8fc | ||
|
|
c4d80d133d | ||
|
|
cebe96c001 | ||
|
|
4d2369ce21 | ||
|
|
5293ab4df1 | ||
|
|
e53c01c6c5 | ||
|
|
03faa27787 | ||
|
|
868dd5f0a5 | ||
|
|
fa58ce53cd | ||
|
|
0a0098fdfb | ||
|
|
a5a48d07f6 | ||
|
|
7b16d5c92d | ||
|
|
ee147c14f1 | ||
|
|
e86d5ba25b | ||
|
|
149ca6f636 | ||
|
|
e4223760b0 | ||
|
|
9d3dd4e082 | ||
|
|
5a4ff33bf4 | ||
|
|
a059fa12e9 | ||
|
|
0628d8f1c9 | ||
|
|
19e2d51190 | ||
|
|
60fab42b3f | ||
|
|
469404c6e1 | ||
|
|
c9756e5b57 | ||
|
|
601d24e930 | ||
|
|
b2c16674f2 | ||
|
|
13da804b5e | ||
|
|
c5ca7b6f8c | ||
|
|
f4b68c0dd4 | ||
|
|
4407f70052 | ||
|
|
8bb52a485a | ||
|
|
9fc18d5ce0 | ||
|
|
ada4f400b5 | ||
|
|
06048b87ee | ||
|
|
05dde1db01 | ||
|
|
b5b32c5b3c | ||
|
|
3f0e2078de | ||
|
|
21470bb409 | ||
|
|
772bb87d5c | ||
|
|
dab172fa1d | ||
|
|
a70c5112cd | ||
|
|
7cb423c046 | ||
|
|
4547b35641 | ||
|
|
4c87f9a021 | ||
|
|
4b08c67e06 | ||
|
|
9f5bc9ddfe | ||
|
|
8221db795a | ||
|
|
68b4418956 | ||
|
|
fa09ebfd82 | ||
|
|
b399ffa765 | ||
|
|
180f4667c1 | ||
|
|
9455373611 | ||
|
|
aa804d89c0 | ||
|
|
3ef51a5d1a | ||
|
|
e61089c659 | ||
|
|
97625cf29b | ||
|
|
a5dc6c27aa | ||
|
|
26a51bafc9 | ||
|
|
f40e09d156 | ||
|
|
81650bc8f6 | ||
|
|
c87caafeb6 | ||
|
|
195b26d90f | ||
|
|
7e0189ca84 | ||
|
|
192706f2a8 | ||
|
|
a4ce8ae07d | ||
|
|
e04a980af1 | ||
|
|
47d40eb6b0 | ||
|
|
fc4a39cc7d | ||
|
|
44e1fd9f14 | ||
|
|
02cc5a215f | ||
|
|
d1e8d50c43 | ||
|
|
18bb2d0719 | ||
|
|
45df311dd7 | ||
|
|
62888b4004 | ||
|
|
76c389dba0 | ||
|
|
78fa98c000 | ||
|
|
e9f9e08450 | ||
|
|
e3c59b0aa7 | ||
|
|
705dce7838 | ||
|
|
0fb55981ba | ||
|
|
89378e29ae | ||
|
|
cce35270ec | ||
|
|
d78180bf97 | ||
|
|
0ab415de3e | ||
|
|
ff3969caeb | ||
|
|
c82cc9f8d6 | ||
|
|
ef5c71bd8b | ||
|
|
bd6be3d23b | ||
|
|
0e6deab9c9 | ||
|
|
6cd9e2be32 | ||
|
|
ac8dab1e88 | ||
|
|
38ed725c2c | ||
|
|
a210bad25e | ||
|
|
6929a4f0f8 | ||
|
|
52dacfa5f2 | ||
|
|
27efe86f9c | ||
|
|
882b9055c7 | ||
|
|
e089089413 | ||
|
|
197932752e | ||
|
|
f0b2bdaf34 | ||
|
|
b96362c0f1 | ||
|
|
67f241cd7a | ||
|
|
c8af0bebf7 | ||
|
|
4f35e799a6 | ||
|
|
eb2a52dd26 | ||
|
|
189b1068ae | ||
|
|
7a3b60a5d7 | ||
|
|
99f06fc093 | ||
|
|
22917bca19 | ||
|
|
7f0e25dcba | ||
|
|
d90c9b1cb2 | ||
|
|
c426055f17 | ||
|
|
18c9010b63 | ||
|
|
c3edac62ef | ||
|
|
755de18fd5 | ||
|
|
641dc25076 | ||
|
|
1d58ea785f | ||
|
|
f53dff5043 | ||
|
|
74d1a31f49 | ||
|
|
d1063ab70b | ||
|
|
f4c919d9ec | ||
|
|
aeb23dbaa9 | ||
|
|
6d4f0c0cdd | ||
|
|
303138f309 | ||
|
|
ad373a3dce | ||
|
|
2150fa58f2 | ||
|
|
ece4841b5c | ||
|
|
8103220c05 | ||
|
|
66d500f08d | ||
|
|
5f8e7c7ba7 | ||
|
|
7b8eee6b25 | ||
|
|
1d5947c602 | ||
|
|
53e4028952 | ||
|
|
b38a8d99e5 | ||
|
|
6c4971ae25 | ||
|
|
d1f5ff0f59 | ||
|
|
1d297601e8 | ||
|
|
d9fface0be | ||
|
|
7d5db917da | ||
|
|
6e7529723d | ||
|
|
6cb64b3707 | ||
|
|
bb1c0b809f | ||
|
|
8bcff6138c | ||
|
|
e78d84ee59 | ||
|
|
c23bcb66ce | ||
|
|
5fddcef3ea | ||
|
|
e1e46c6eb1 | ||
|
|
13ad0c8464 | ||
|
|
7700b50470 | ||
|
|
fc4d6165b4 | ||
|
|
251c8aaefc | ||
|
|
1337d38ada | ||
|
|
f5c66e41cb | ||
|
|
0e7da017fe | ||
|
|
f0262ffaae | ||
|
|
36203af88e | ||
|
|
dd2b8bc6c7 | ||
|
|
463065ac21 | ||
|
|
d064e6e96e | ||
|
|
b1ed2df208 | ||
|
|
1fe4ef135c | ||
|
|
e376b5d472 | ||
|
|
952a9b2c41 | ||
|
|
03458dc641 | ||
|
|
14df5b72af | ||
|
|
338968031b | ||
|
|
1aac245b93 | ||
|
|
1faff323c1 | ||
|
|
e7280c7ae2 | ||
|
|
4c38619b5d | ||
|
|
b4e5c5cc1f | ||
|
|
b0dbd84f7f |
31
.github/workflows/nodejs.yml
vendored
Normal file
31
.github/workflows/nodejs.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x, 12.x, 13.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run test-extensive
|
||||
env:
|
||||
CI: true
|
||||
@@ -17,7 +17,7 @@
|
||||
"useCollapsibles": true,
|
||||
"collapse": true,
|
||||
"resources": {
|
||||
"y-js.org": "yjs.website"
|
||||
"yjs.dev": "Yjs website"
|
||||
},
|
||||
"logo": {
|
||||
"url": "https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png",
|
||||
|
||||
4
.markdownlint.json
Normal file
4
.markdownlint.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"default": true,
|
||||
"no-inline-html": false
|
||||
}
|
||||
557
README.v13.md
557
README.v13.md
@@ -1,557 +0,0 @@
|
||||
# 
|
||||
> A CRDT framework with a powerful abstraction of shared data
|
||||
|
||||
Yjs is a [CRDT implementation](#Yjs-CRDT-Algorithm) that exposes its internal data structure as *shared types*. Shared types are common data types like `Map` or `Array` with superpowers: changes are automatically distributed to other peers and merged without merge conflicts.
|
||||
|
||||
Yjs is **network agnostic** (p2p!), supports many existing **rich text editors**, **offline editing**, **version snapshots**, **undo/redo** and **shared cursors**. It scales well with an unlimited number of users and is well suited for even large documents.
|
||||
|
||||
* Chat: [https://gitter.im/y-js/yjs](https://gitter.im/y-js/yjs)
|
||||
* Demos: [https://github.com/y-js/yjs-demos](https://github.com/y-js/yjs-demos)
|
||||
* Benchmarks: [https://github.com/dmonad/crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks)
|
||||
|
||||
# Table of Contents
|
||||
|
||||
* [Overview](#Overview)
|
||||
* [Bindings](#Bindings)
|
||||
* [Providers](#Providers)
|
||||
* [Getting Started](#Getting-Started)
|
||||
* [API](#API)
|
||||
* [Shared Types](#Shared-Types)
|
||||
* [Y.Doc](#Y.Doc)
|
||||
* [Document Updates](#Document-Updates)
|
||||
* [Relative Positions](#Relative-Positions)
|
||||
* [Miscellaneous](#Miscellaneous)
|
||||
* [Typescript Declarations](#Typescript-Declarations)
|
||||
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
|
||||
* [Evaluation](#Evaluation)
|
||||
* [Existing shared editing libraries](#Exisisting-Javascript-Libraries)
|
||||
* [CRDT Algorithms](#CRDT-Algorithms)
|
||||
* [Comparison of CRDT with OT](#Comparing-CRDT-with-OT)
|
||||
* [Comparison of CRDT Algorithms](#Comparing-CRDT-Algorithms)
|
||||
* [Comparison of Yjs with other Implementations](#Comparing-Yjs-with-other-Implementations)
|
||||
* [License and Author](#License-and-Author)
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
This repository contains a collection of shared types that can be observed for changes and manipulated concurrently. Network functionality and two-way-bindings are implemented in separate modules.
|
||||
|
||||
### Bindings
|
||||
|
||||
| Name | Cursors | Binding | Demo |
|
||||
|---|:-:|---|---|
|
||||
| [ProseMirror](https://prosemirror.net/) | ✔ | [y-prosemirror](http://github.com/y-js/y-prosemirror) | [demo](https://yjs-demos.now.sh/prosemirror/) |
|
||||
| [Quill](https://quilljs.com/) | | [y-quill](http://github.com/y-js/y-quill) | [demo](https://yjs-demos.now.sh/quill/) |
|
||||
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/y-js/y-codemirror) | [demo](https://yjs-demos.now.sh/codemirror/) |
|
||||
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](http://github.com/y-js/y-monaco) | [demo](https://yjs-demos.now.sh/monaco/) |
|
||||
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/y-js/y-ace) | [demo](https://yjs-demos.now.sh/ace/) |
|
||||
| [Textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) | | [y-textarea](http://github.com/y-js/y-textarea) | [demo](https://yjs-demos.now.sh/textarea/) |
|
||||
| [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) | | [y-dom](http://github.com/y-js/y-dom) | [demo](https://yjs-demos.now.sh/dom/) |
|
||||
|
||||
|
||||
### Providers
|
||||
|
||||
Setting up the communication between clients, managing awareness information, and storing shared data for offline usage is quite a hassle. **Providers** manage all that for you and are the perfect starting point for your collaborative app.
|
||||
|
||||
<dl>
|
||||
<dt><a href="http://github.com/y-js/y-websocket">y-websocket</a></dt>
|
||||
<dd>A module that contains a simple websocket backend and a websocket client that connects to that backend. The backend can be extended to persist updates in a leveldb database.</dd>
|
||||
<dt><a href="http://github.com/y-js/y-mesh">y-mesh</a></dt>
|
||||
<dd>[WIP] Creates a connected graph of webrtc connections with a high <a href="https://en.wikipedia.org/wiki/Strength_of_a_graph">strength</a>. It requires a signalling server that connects a client to the first peer. But after that the network manages itself. It is well suited for large and small networks.</dd>
|
||||
<dt><a href="http://github.com/y-js/y-dat">y-dat</a></dt>
|
||||
<dd>[WIP] Write document updates effinciently to the dat network using <a href="https://github.com/kappa-db/multifeed">multifeed</a>. Each client has an append-only log of CRDT local updates (hypercore). Multifeed manages and sync hypercores and y-dat listens to changes and applies them to the Yjs document.</dd>
|
||||
</dl>
|
||||
|
||||
## Getting Started
|
||||
|
||||
Install Yjs and a provider with your favorite package manager.
|
||||
|
||||
```sh
|
||||
npm i yjs@13.0.0-82 y-websocket@1.0.0-3 y-textarea
|
||||
```
|
||||
|
||||
**Start the y-websocket server**
|
||||
|
||||
```sh
|
||||
PORT=1234 node ./node_modules/y-websocket/bin/server.js
|
||||
```
|
||||
|
||||
**Example: Textarea Binding**
|
||||
|
||||
This is a complete example on how to create a connection to a [y-websocket](https://github.com/y-js/y-websocket) server instance, sync the shared document to all clients in a *room*, and bind a Y.Text type to a dom textarea. All changes to the textarea are automatically shared with everyone in the same room.
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { TextareaBinding } from 'y-textarea'
|
||||
|
||||
const doc = Y.Doc()
|
||||
const provider = new WebsocketProvider('ws://localhost:1234', 'roomname', doc)
|
||||
|
||||
// Define a shared type on the document.
|
||||
const ytext = doc.getText('my resume')
|
||||
|
||||
// use data bindings to bind types to editors
|
||||
const binding = new TextareaBinding(ytext, document.querySelector('textarea'))
|
||||
```
|
||||
|
||||
**Example: Observe types**
|
||||
|
||||
```js
|
||||
const yarray = doc.getArray('my-array')
|
||||
yarray.observe(event => {
|
||||
console.log('yarray was modified')
|
||||
})
|
||||
// every time a local or remote client modifies yarray, the observer is called
|
||||
yarray.insert(0, ['val']) // => "yarray was modified"
|
||||
```
|
||||
|
||||
**Example: Nest types**
|
||||
|
||||
Remember, shared types are just plain old data types. The only limitation is that a shared type must exist only once in the shared document.
|
||||
|
||||
```js
|
||||
const ymap = doc.getMap('map')
|
||||
const foodArray = new Y.Array()
|
||||
foodArray.insert(0, ['apple', 'banana'])
|
||||
ymap.set('food', foodArray)
|
||||
ymap.get('food') === foodArray // => true
|
||||
ymap.set('fruit', foodArray) // => Error! foodArray is already defined on the shared document
|
||||
```
|
||||
|
||||
Now you understand how types are defined on a shared document. Next you can jump to the [demo repository](https://github.com/y-js/yjs-demos) or continue reading the API docs.
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
```
|
||||
|
||||
### Shared Types
|
||||
|
||||
<details>
|
||||
<summary><b>Y.Array</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A shareable Array-like type that supports efficient insert/delete of elements at any position. Internally it uses a linked list of Arrays that is split when necessary.
|
||||
</p>
|
||||
<pre>const yarray = new Y.Array()</pre>
|
||||
<dl>
|
||||
<b><code>insert(index:number, content:Array<object|boolean|Array|string|number|Uint8Array|Y.Type>)</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<Object|boolean|Array|string|number|Uint8Array|Y.Type>)</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>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b><code>map(function(T, number, YArray):M):Array<M></code></b>
|
||||
<dd></dd>
|
||||
<b><code>toArray():Array<object|boolean|Array|string|number|Uint8Array|Y.Type></code></b>
|
||||
<dd>Copies the content of this YArray to a new Array.</dd>
|
||||
<b><code>toJSON():Array<Object|boolean|Array|string|number></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.</dd>
|
||||
<b><code>[Symbol.Iterator]</code></b>
|
||||
<dd>
|
||||
Returns an YArray Iterator that contains the values for each index in the array.
|
||||
<pre>for (let value of yarray) { .. }</pre>
|
||||
</dd>
|
||||
<b><code>observe(function(YArrayEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YArrayEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Y.Map</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A shareable Map type.
|
||||
</p>
|
||||
<pre><code>const ymap = new Y.Map()</code></pre>
|
||||
<dl>
|
||||
<b><code>get(key:string):object|boolean|string|number|Uint8Array|Y.Type</code></b>
|
||||
<dd></dd>
|
||||
<b><code>set(key:string, value:object|boolean|string|number|Uint8Array|Y.Type)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>delete(key:string)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>has(key:string):boolean</code></b>
|
||||
<dd></dd>
|
||||
<b><code>get(index:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>toJSON():Object<string, Object|boolean|Array|string|number></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>[Symbol.Iterator]</code></b>
|
||||
<dd>
|
||||
Returns an Iterator of <code>[key, value]</code> pairs.
|
||||
<pre>for (let [key, value] of ymap) { .. }</pre>
|
||||
</dd>
|
||||
<b><code>entries()</code></b>
|
||||
<dd>
|
||||
Returns an Iterator of <code>[key, value]</code> pairs.
|
||||
</dd>
|
||||
<b><code>values()</code></b>
|
||||
<dd>
|
||||
Returns an Iterator of all values.
|
||||
</dd>
|
||||
<b><code>keys()</code></b>
|
||||
<dd>
|
||||
Returns an Iterator of all keys.
|
||||
</dd>
|
||||
<b><code>observe(function(YMapEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YMapEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Y.Text</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A shareable type that is optimized for shared editing on text. It allows to assign properties to ranges in the text. This makes it possible to implement rich-text bindings to this type.
|
||||
</p>
|
||||
<p>
|
||||
This type can also be transformed to the <a href="https://quilljs.com/docs/delta">delta format</a>. Similarly the YTextEvents compute changes as deltas.
|
||||
</p>
|
||||
<pre>const ytext = new Y.Text()</pre>
|
||||
<dl>
|
||||
<b><code>insert(index:number, content:string, [formattingAttributes:Object<string,string>])</code></b>
|
||||
<dd>
|
||||
Insert a string at <var>index</var> and assign formatting attributes to it.
|
||||
<pre>ytext.insert(0, 'bold text', { bold: true })</pre>
|
||||
</dd>
|
||||
<b><code>delete(index:number, length:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>format(index:number, length:number, formattingAttributes:Object<string,string>)</code></b>
|
||||
<dd>Assign formatting attributes to a range in the text</dd>
|
||||
<b><code>applyDelta(delta)</code></b>
|
||||
<dd>See <a href="https://quilljs.com/docs/delta/">Quill Delta</a></dd>
|
||||
<b><code>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b><code>toString():string</code></b>
|
||||
<dd>Transforms this type, without formatting options, into a string.</dd>
|
||||
<b><code>toJSON():string</code></b>
|
||||
<dd>See <code>toString</code></dd>
|
||||
<b><code>toDelta():Delta</code></b>
|
||||
<dd>Transforms this type to a <a href="https://quilljs.com/docs/delta/">Quill Delta</a></dd>
|
||||
<b><code>observe(function(YTextEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YTextEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>YXmlFragment</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A container that holds an Array of Y.XmlElements.
|
||||
</p>
|
||||
<pre><code>const yxml = new Y.XmlFragment()</code></pre>
|
||||
<dl>
|
||||
<b><code>insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)</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>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b><code>toArray():Array<Y.XmlElement|Y.XmlText></code></b>
|
||||
<dd>Copies the children to a new Array.</dd>
|
||||
<b><code>toDOM():DocumentFragment</code></b>
|
||||
<dd>Transforms this type and all children to new DOM elements.</dd>
|
||||
<b><code>toString():string</code></b>
|
||||
<dd>Get the XML serialization of all descendants.</dd>
|
||||
<b><code>toJSON():string</code></b>
|
||||
<dd>See <code>toString</code>.</dd>
|
||||
<b><code>observe(function(YXmlEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YXmlEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Y.XmlElement</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A shareable type that represents an XML Element. It has a <code>nodeName</code>, attributes, and a list of children. But it makes no effort to validate its content and be actually XML compliant.
|
||||
</p>
|
||||
<pre><code>const yxml = new Y.XmlElement()</code></pre>
|
||||
<dl>
|
||||
<b><code>insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)</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>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b><code>setAttribute(attributeName:string, attributeValue:string)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>removeAttribute(attributeName:string)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>getAttribute(attributeName:string):string</code></b>
|
||||
<dd></dd>
|
||||
<b><code>getAttributes(attributeName:string):Object<string,string></code></b>
|
||||
<dd></dd>
|
||||
<b><code>toArray():Array<Y.XmlElement|Y.XmlText></code></b>
|
||||
<dd>Copies the children to a new Array.</dd>
|
||||
<b><code>toDOM():Element</code></b>
|
||||
<dd>Transforms this type and all children to a new DOM element.</dd>
|
||||
<b><code>toString():string</code></b>
|
||||
<dd>Get the XML serialization of all descendants.</dd>
|
||||
<b><code>toJSON():string</code></b>
|
||||
<dd>See <code>toString</code>.</dd>
|
||||
<b><code>observe(function(YXmlEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YXmlEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
### Y.Doc
|
||||
|
||||
```js
|
||||
const doc = new Y.Doc()
|
||||
```
|
||||
|
||||
<dl>
|
||||
<b><code>clientID</code></b>
|
||||
<dd>A unique id that identifies this client. (readonly)</dd>
|
||||
<b><code>transact(function(Transaction):void [, origin:any])</code></b>
|
||||
<dd>Every change on the shared document happens in a transaction. Observer calls and the <code>update</code> event are called after each transaction. You should <i>bundle</i> changes into a single transaction to reduce the amount of event calls. I.e. <code>doc.transact(() => { yarray.insert(..); ymap.set(..) })</code> triggers a single change event. <br>You can specify an optional <code>origin</code> parameter that is stored on <code>transaction.origin</code> and <code>on('update', (update, origin) => ..)</code>.</dd>
|
||||
<b><code>get(string, Y.[TypeClass]):[Type]</code></b>
|
||||
<dd>Define a shared type.</dd>
|
||||
<b><code>getArray(string):Y.Array</code></b>
|
||||
<dd>Define a shared Y.Array type. Is equivalent to <code>y.get(string, Y.Array)</code>.</dd>
|
||||
<b><code>getMap(string):Y.Map</code></b>
|
||||
<dd>Define a shared Y.Map type. Is equivalent to <code>y.get(string, Y.Map)</code>.</dd>
|
||||
<b><code>getXmlFragment(string):Y.XmlFragment</code></b>
|
||||
<dd>Define a shared Y.XmlFragment type. Is equivalent to <code>y.get(string, Y.XmlFragment)</code>.</dd>
|
||||
<b><code>on(string, function)</code></b>
|
||||
<dd>Register an event listener on the shared type</dd>
|
||||
<b><code>off(string, function)</code></b>
|
||||
<dd>Unregister an event listener from the shared type</dd>
|
||||
</dl>
|
||||
|
||||
|
||||
#### Y.Doc Events
|
||||
<dl>
|
||||
<b><code>on('update', function(updateMessage:Uint8Array, origin:any, Y.Doc):void)</code></b>
|
||||
<dd>Listen to document updates. Document updates must be transmitted to all other peers. You can apply document updates in any order and multiple times.</dd>
|
||||
<b><code>on('beforeTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
|
||||
<dd>Emitted before each transaction.</dd>
|
||||
<b><code>on('afterTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
|
||||
<dd>Emitted after each transaction.</dd>
|
||||
</dl>
|
||||
|
||||
### Document Updates
|
||||
|
||||
Changes on the shared document are encoded into *document updates*. Document updates are *commutative* and *idempotent*. This means that they can be applied in any order and multiple times.
|
||||
|
||||
**Example: Listen to update events and apply them on remote client**
|
||||
```js
|
||||
const doc1 = new Y.Doc()
|
||||
const doc2 = new Y.Doc()
|
||||
|
||||
doc1.on('update', update => {
|
||||
Y.applyUpdate(doc2, update)
|
||||
})
|
||||
|
||||
doc2.on('update', update => {
|
||||
Y.applyUpdate(doc1, update)
|
||||
})
|
||||
|
||||
// All changes are also applied to the other document
|
||||
doc1.getArray('myarray').insert(0, ['Hello doc2, you got this?'])
|
||||
doc2.getArray('myarray').get(0) // => 'Hello doc2, you got this?'
|
||||
```
|
||||
|
||||
Yjs internally maintains a [state vector](#State-Vector) that denotes the next expected clock from each client. In a different interpretation it holds the number of structs created by each client. When two clients sync, you can either exchange the complete document structure or only the differences by sending the state vector to compute the differences.
|
||||
|
||||
**Example: Sync two clients by exchanging the complete document structure**
|
||||
|
||||
```js
|
||||
const state1 = Y.encodeStateAsUpdate(ydoc1)
|
||||
const state2 = Y.encodeStateAsUpdate(ydoc2)
|
||||
Y.applyUpdate(ydoc1, state2)
|
||||
Y.applyUpdate(ydoc2, state1)
|
||||
```
|
||||
|
||||
**Example: Sync two clients by computing the differences**
|
||||
|
||||
This example shows how to sync two clients with the minimal amount of exchanged data by computing only the differences using the state vector of the remote client. Syncing clients using the state vector requires another roundtrip, but can safe a lot of bandwidth.
|
||||
|
||||
```js
|
||||
const stateVector1 = Y.encodeStateVector(ydoc1)
|
||||
const stateVector2 = Y.encodeStateVector(ydoc2)
|
||||
const diff1 = Y.encodeStateAsUpdate(ydoc1, stateVector2)
|
||||
const diff2 = Y.encodeStateAsUpdate(ydoc2, stateVector1)
|
||||
Y.applyUpdate(ydoc1, diff2)
|
||||
Y.applyUpdate(ydoc2, diff1)
|
||||
```
|
||||
|
||||
<dl>
|
||||
<b><code>Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])</code></b>
|
||||
<dd>Apply a document update on the shared document. Optionally you can specify <code>transactionOrigin</code> that will be stored on <code>transaction.origin</code> and <code>ydoc.on('update', (update, origin) => ..)</code>.</dd>
|
||||
<b><code>Y.encodeStateAsUpdate(Y.Doc, [encodedTargetStateVector:Uint8Array]):Uint8Array</code></b>
|
||||
<dd>Encode the document state as a single update message that can be applied on the remote document. Optionally specify the target state vector to only write the differences to the update message.</dd>
|
||||
<b><code>Y.encodeStateVector(Y.Doc):Uint8Array</code></b>
|
||||
<dd>Computes the state vector and encodes it into an Uint8Array.</dd>
|
||||
</dl>
|
||||
|
||||
### Relative Positions
|
||||
> This API is not stable yet
|
||||
|
||||
This feature is intended for managing selections / cursors. When working with other users that manipulate the shared document, you can't trust that an index position (an integer) will stay at the intended location. A *relative position* is fixated to an element in the shared document and is not affected by remote changes. I.e. given the document `"a|c"`, the relative position is attached to `c`. When a remote user modifies the document by inserting a character before the cursor, the cursor will stay attached to the character `c`. `insert(1, 'x')("a|c") = "ax|c"`. When the *relative position* is set to the end of the document, it will stay attached to the end of the document.
|
||||
|
||||
**Example: Transform to RelativePosition and back**
|
||||
```js
|
||||
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
|
||||
const pos = Y.createAbsolutePositionFromRelativePosition(relPos, doc)
|
||||
pos.type === ytext // => true
|
||||
pos.index === 2 // => true
|
||||
```
|
||||
|
||||
**Example: Send relative position to remote client (json)**
|
||||
```js
|
||||
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
|
||||
const encodedRelPos = JSON.stringify(relPos)
|
||||
// send encodedRelPos to remote client..
|
||||
const parsedRelPos = JSON.parse(encodedRelPos)
|
||||
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
|
||||
pos.type === remoteytext // => true
|
||||
pos.index === 2 // => true
|
||||
```
|
||||
|
||||
**Example: Send relative position to remote client (Uint8Array)**
|
||||
```js
|
||||
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
|
||||
const encodedRelPos = Y.encodeRelativePosition(relPos)
|
||||
// send encodedRelPos to remote client..
|
||||
const parsedRelPos = Y.decodeRelativePosition(encodedRelPos)
|
||||
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
|
||||
pos.type === remoteytext // => true
|
||||
pos.index === 2 // => true
|
||||
```
|
||||
|
||||
<dl>
|
||||
<b><code>Y.createRelativePositionFromTypeIndex(Uint8Array|Y.Type, number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>Y.encodeRelativePosition(RelativePosition):Uint8Array</code></b>
|
||||
<dd></dd>
|
||||
<b><code>Y.decodeRelativePosition(Uint8Array):RelativePosition</code></b>
|
||||
<dd></dd>
|
||||
</dl>
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
### Typescript Declarations
|
||||
|
||||
Yjs has type descriptions. But until [this ticket](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, this is how you can make use of Yjs type declarations.
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
},
|
||||
"maxNodeModuleJsDepth": 5
|
||||
}
|
||||
```
|
||||
|
||||
## Yjs CRDT Algorithm
|
||||
|
||||
*Conflict-free replicated data types* (CRDT) for collaborative editing are an alternative approach to *operational transformation* (OT). A very simple differenciation between the two approaches is that OT attempts to transform index positions to ensure convergence (all clients end up with the same content), while CRDTs use mathematical models that usually do not involve index transformations, like linked lists. OT is currently the de-facto standard for shared editing on text. OT approaches that support shared editing without a central source of truth (a central server) require too much bookkeeping to be viable in practice. CRDTs are better suited for distributed systems, provide additional guarantees that the document can be synced with remote clients, and do not require a central source of truth.
|
||||
|
||||
Yjs implements a modified version of the algorithm described in [this paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types). I will eventually publish a paper that describes why this approach works so well in practice. Note: Since operations make up the document structure, we prefer the term *struct* now.
|
||||
|
||||
CRDTs suitable for shared text editing suffer from the fact that they only grow in size. There are CRDTs that do not grow in size, but they do not have the characteristics that are benificial for shared text editing (like intention preservation). Yjs implements many improvements to the original algorithm that diminish the trade-off that the document only grows in size. We can't garbage collect deleted structs (tombstones) while ensuring a unique order of the structs. But we can 1. merge preceeding structs into a single struct to reduce the amount of meta information, 2. we can delete content from the struct if it is deleted, and 3. we can garbage collect tombstones if we don't care about the 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 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'}]`.
|
||||
2. When a struct that contains content (e.g. `ItemString`) is deleted, the struct will be replaced with an `ItemDeleted` that does not contain content anymore.
|
||||
3. When a type is deleted, all child elements are transformed to `GC` structs. A `GC` struct only denotes the existence of a struct and that it is deleted. `GC` structs can always be merged with other `GC` structs if the id's are adjacent.
|
||||
|
||||
Especially when working on structured content (e.g. shared editing on ProseMirror), these improvements yield very good results when [benchmarking](https://github.com/dmonad/crdt-benchmarks) random document edits. In practice they show even better results, because users usually edit text in sequence, resulting in structs that can easily be merged. The benchmarks show that even in the worst case scenario that a user edits text from right to left, Yjs achieves good performance even for huge documents.
|
||||
|
||||
#### State Vector
|
||||
Yjs has the ability to exchange only the differences when syncing two clients. We use lamport timestamps to identify structs and to track in which order a client created them. Each struct has an `struct.id = { client: number, clock: number}` that uniquely identifies a struct. We define the next expected `clock` by each client as the *state vector*. This data structure is similar to the [version vectors](https://en.wikipedia.org/wiki/Version_vector) data structure. But we use state vectors only to describe the state of the local document, so we can compute the missing struct of the remote client. We do not use it to track causality.
|
||||
|
||||
## License and Author
|
||||
|
||||
Yjs and all related projects are [**MIT licensed**](./LICENSE).
|
||||
|
||||
Yjs is based on my research as a student at the [RWTH i5](http://dbis.rwth-aachen.de/). Now I am working on Yjs in my spare time.
|
||||
|
||||
Fund this project by donating on [Patreon](https://www.patreon.com/dmonad) or hiring [me](https://github.com/dmonad) for professional support.
|
||||
3995
package-lock.json
generated
3995
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
63
package.json
63
package.json
@@ -1,23 +1,27 @@
|
||||
{
|
||||
"name": "yjs",
|
||||
"version": "13.0.0-88",
|
||||
"version": "13.2.0",
|
||||
"description": "Shared Editing Library",
|
||||
"main": "./dist/yjs.js",
|
||||
"main": "./dist/yjs.cjs",
|
||||
"module": "./dist/yjs.mjs",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "npm run dist && PRODUCTION=1 node ./dist/tests.js --repitition-time 50 --production",
|
||||
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000",
|
||||
"dist": "rm -rf dist && rollup -c",
|
||||
"test": "npm run dist && node ./dist/tests.cjs --repitition-time 50",
|
||||
"test-extensive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repitition-time 10000",
|
||||
"dist": "rm -rf dist && rollup -c && tsc",
|
||||
"watch": "rollup -wc",
|
||||
"lint": "standard && tsc",
|
||||
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.md --package ./package.json || true",
|
||||
"serve-docs": "npm run docs && serve ./docs/",
|
||||
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.js --repitition-time 1000",
|
||||
"postversion": "git push && git push --tags",
|
||||
"debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'",
|
||||
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.js",
|
||||
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.js"
|
||||
"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 --repitition-time 1000",
|
||||
"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"
|
||||
},
|
||||
"files": [
|
||||
"dist/*",
|
||||
@@ -38,31 +42,38 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/y-js/yjs.git"
|
||||
"url": "https://github.com/yjs/yjs.git"
|
||||
},
|
||||
"keywords": [
|
||||
"crdt"
|
||||
"Yjs",
|
||||
"CRDT",
|
||||
"offline",
|
||||
"shared editing",
|
||||
"concurrency",
|
||||
"collaboration"
|
||||
],
|
||||
"author": "Kevin Jahns",
|
||||
"email": "kevin.jahns@rwth-aachen.de",
|
||||
"email": "kevin.jahns@protonmail.com",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/y-js/yjs/issues"
|
||||
"url": "https://github.com/yjs/yjs/issues"
|
||||
},
|
||||
"homepage": "http://y-js.org",
|
||||
"homepage": "https://yjs.dev",
|
||||
"dependencies": {
|
||||
"lib0": "0.0.5"
|
||||
"lib0": "^0.2.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^11.1.0",
|
||||
"@rollup/plugin-node-resolve": "^7.1.3",
|
||||
"concurrently": "^3.6.1",
|
||||
"jsdoc": "^3.6.2",
|
||||
"live-server": "^1.2.1",
|
||||
"rollup": "^1.11.3",
|
||||
"http-server": "^0.12.3",
|
||||
"jsdoc": "^3.6.4",
|
||||
"markdownlint-cli": "^0.23.1",
|
||||
"rollup": "^1.32.1",
|
||||
"rollup-cli": "^1.0.9",
|
||||
"rollup-plugin-node-resolve": "^4.2.4",
|
||||
"standard": "^11.0.1",
|
||||
"standard": "^14.3.4",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^3.4.5",
|
||||
"y-protocols": "0.0.6"
|
||||
"typescript": "^3.9.3",
|
||||
"y-protocols": "^0.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import nodeResolve from '@rollup/plugin-node-resolve'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
|
||||
const localImports = process.env.LOCALIMPORTS
|
||||
|
||||
@@ -37,23 +38,27 @@ const debugResolve = {
|
||||
|
||||
export default [{
|
||||
input: './src/index.js',
|
||||
output: [{
|
||||
output: {
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.js',
|
||||
file: 'dist/yjs.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
paths: path => {
|
||||
if (/^lib0\//.test(path)) {
|
||||
return `lib0/dist/${path.slice(5)}`
|
||||
return `lib0/dist/${path.slice(5, -3)}.cjs`
|
||||
}
|
||||
return path
|
||||
}
|
||||
}, {
|
||||
},
|
||||
external: id => /^lib0\//.test(id)
|
||||
}, {
|
||||
input: './src/index.js',
|
||||
output: {
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.mjs',
|
||||
format: 'es',
|
||||
format: 'esm',
|
||||
sourcemap: true
|
||||
}],
|
||||
},
|
||||
external: id => /^lib0\//.test(id)
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
@@ -66,8 +71,24 @@ export default [{
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
mainFields: ['module', 'browser', 'main']
|
||||
})
|
||||
}),
|
||||
commonjs()
|
||||
]
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
output: {
|
||||
name: 'test',
|
||||
file: 'dist/tests.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
mainFields: ['module', 'main']
|
||||
}),
|
||||
commonjs()
|
||||
],
|
||||
external: ['isomorphic.js']
|
||||
}]
|
||||
|
||||
19
src/index.js
19
src/index.js
@@ -21,10 +21,12 @@ export {
|
||||
ContentEmbed,
|
||||
ContentFormat,
|
||||
ContentJSON,
|
||||
ContentAny,
|
||||
ContentString,
|
||||
ContentType,
|
||||
AbstractType,
|
||||
RelativePosition,
|
||||
getTypeChildren,
|
||||
createRelativePositionFromTypeIndex,
|
||||
createRelativePositionFromJSON,
|
||||
createAbsolutePositionFromRelativePosition,
|
||||
@@ -36,11 +38,26 @@ export {
|
||||
compareIDs,
|
||||
getState,
|
||||
Snapshot,
|
||||
createSnapshot,
|
||||
createDeleteSet,
|
||||
createDeleteSetFromStructStore,
|
||||
snapshot,
|
||||
emptySnapshot,
|
||||
findRootTypeKey,
|
||||
typeListToArraySnapshot,
|
||||
typeMapGetSnapshot,
|
||||
iterateDeletedStructs,
|
||||
applyUpdate,
|
||||
readUpdate,
|
||||
encodeStateAsUpdate,
|
||||
encodeStateVector
|
||||
encodeStateVector,
|
||||
UndoManager,
|
||||
decodeSnapshot,
|
||||
encodeSnapshot,
|
||||
isDeleted,
|
||||
isParentOf,
|
||||
equalSnapshots,
|
||||
PermanentUserData, // @TODO experimental
|
||||
tryGc,
|
||||
transact
|
||||
} from './internals.js'
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
|
||||
export * from './utils/DeleteSet.js'
|
||||
export * from './utils/Doc.js'
|
||||
export * from './utils/encoding.js'
|
||||
export * from './utils/EventHandler.js'
|
||||
export * from './utils/ID.js'
|
||||
export * from './utils/isParentOf.js'
|
||||
export * from './utils/PermanentUserData.js'
|
||||
export * from './utils/RelativePosition.js'
|
||||
export * from './utils/Snapshot.js'
|
||||
export * from './utils/StructStore.js'
|
||||
export * from './utils/Transaction.js'
|
||||
// export * from './utils/UndoManager.js'
|
||||
export * from './utils/Doc.js'
|
||||
export * from './utils/UndoManager.js'
|
||||
export * from './utils/YEvent.js'
|
||||
|
||||
export * from './types/AbstractType.js'
|
||||
@@ -27,8 +30,7 @@ export * from './structs/ContentDeleted.js'
|
||||
export * from './structs/ContentEmbed.js'
|
||||
export * from './structs/ContentFormat.js'
|
||||
export * from './structs/ContentJSON.js'
|
||||
export * from './structs/ContentAny.js'
|
||||
export * from './structs/ContentString.js'
|
||||
export * from './structs/ContentType.js'
|
||||
export * from './structs/Item.js'
|
||||
|
||||
export * from './utils/encoding.js'
|
||||
|
||||
@@ -6,24 +6,17 @@ import {
|
||||
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class AbstractStruct {
|
||||
/**
|
||||
* @param {ID} id
|
||||
* @param {number} length
|
||||
*/
|
||||
constructor (id, length) {
|
||||
/**
|
||||
* The uniqe identifier of this struct.
|
||||
* @type {ID}
|
||||
* @readonly
|
||||
*/
|
||||
this.id = id
|
||||
this.length = length
|
||||
this.deleted = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge this struct with the item to the right.
|
||||
* This method is already assuming that `this.id.clock + this.length === this.id.clock`.
|
||||
@@ -34,55 +27,21 @@ export class AbstractStruct {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
* @param {number} offset
|
||||
* @param {number} encodingRef
|
||||
* @private
|
||||
*/
|
||||
write (encoder, offset, encodingRef) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
integrate (transaction) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class AbstractStructRef {
|
||||
/**
|
||||
* @param {ID} id
|
||||
*/
|
||||
constructor (id) {
|
||||
/**
|
||||
* @type {Array<ID>}
|
||||
*/
|
||||
this._missing = []
|
||||
/**
|
||||
* The uniqe identifier of this type.
|
||||
* @type {ID}
|
||||
*/
|
||||
this.id = id
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @return {Array<ID|null>}
|
||||
*/
|
||||
getMissing (transaction) {
|
||||
return this._missing
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {number} offset
|
||||
* @return {AbstractStruct}
|
||||
*/
|
||||
toStruct (transaction, store, offset) {
|
||||
integrate (transaction, offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
111
src/structs/ContentAny.js
Normal file
111
src/structs/ContentAny.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Transaction, Item, StructStore // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
export class ContentAny {
|
||||
/**
|
||||
* @param {Array<any>} arr
|
||||
*/
|
||||
constructor (arr) {
|
||||
/**
|
||||
* @type {Array<any>}
|
||||
*/
|
||||
this.arr = arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return this.arr.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return this.arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentAny}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentAny(this.arr)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentAny}
|
||||
*/
|
||||
splice (offset) {
|
||||
const right = new ContentAny(this.arr.slice(offset))
|
||||
this.arr = this.arr.slice(0, offset)
|
||||
return right
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentAny} right
|
||||
* @return {boolean}
|
||||
*/
|
||||
mergeWith (right) {
|
||||
this.arr = this.arr.concat(right.arr)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
*/
|
||||
integrate (transaction, item) {}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
delete (transaction) {}
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
gc (store) {}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {number} offset
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
const len = this.arr.length
|
||||
encoding.writeVarUint(encoder, len - offset)
|
||||
for (let i = offset; i < len; i++) {
|
||||
const c = this.arr[i]
|
||||
encoding.writeAny(encoder, c)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getRef () {
|
||||
return 8
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {ContentAny}
|
||||
*/
|
||||
export const readContentAny = decoder => {
|
||||
const len = decoding.readVarUint(decoder)
|
||||
const cs = []
|
||||
for (let i = 0; i < len; i++) {
|
||||
cs.push(decoding.readAny(decoder))
|
||||
}
|
||||
return new ContentAny(cs)
|
||||
}
|
||||
@@ -7,9 +7,6 @@ import * as decoding from 'lib0/decoding.js'
|
||||
import * as buffer from 'lib0/buffer.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class ContentBinary {
|
||||
/**
|
||||
* @param {Uint8Array} content
|
||||
@@ -17,30 +14,35 @@ export class ContentBinary {
|
||||
constructor (content) {
|
||||
this.content = content
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return [this.content]
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentBinary}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentBinary(this.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentBinary}
|
||||
@@ -48,6 +50,7 @@ export class ContentBinary {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentBinary} right
|
||||
* @return {boolean}
|
||||
@@ -55,6 +58,7 @@ export class ContentBinary {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -75,6 +79,7 @@ export class ContentBinary {
|
||||
write (encoder, offset) {
|
||||
encoding.writeVarUint8Array(encoder, this.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -84,8 +89,6 @@ export class ContentBinary {
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {ContentBinary}
|
||||
*/
|
||||
|
||||
@@ -7,9 +7,6 @@ import {
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class ContentDeleted {
|
||||
/**
|
||||
* @param {number} len
|
||||
@@ -17,30 +14,35 @@ export class ContentDeleted {
|
||||
constructor (len) {
|
||||
this.len = len
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return this.len
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentDeleted}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentDeleted(this.len)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentDeleted}
|
||||
@@ -50,6 +52,7 @@ export class ContentDeleted {
|
||||
this.len = offset
|
||||
return right
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentDeleted} right
|
||||
* @return {boolean}
|
||||
@@ -58,6 +61,7 @@ export class ContentDeleted {
|
||||
this.len += right.len
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -66,6 +70,7 @@ export class ContentDeleted {
|
||||
addToDeleteSet(transaction.deleteSet, item.id, this.len)
|
||||
item.deleted = true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
@@ -81,6 +86,7 @@ export class ContentDeleted {
|
||||
write (encoder, offset) {
|
||||
encoding.writeVarUint(encoder, this.len - offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
|
||||
@@ -17,30 +17,35 @@ export class ContentEmbed {
|
||||
constructor (embed) {
|
||||
this.embed = embed
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return [this.embed]
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentEmbed}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentEmbed(this.embed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentEmbed}
|
||||
@@ -48,6 +53,7 @@ export class ContentEmbed {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentEmbed} right
|
||||
* @return {boolean}
|
||||
@@ -55,6 +61,7 @@ export class ContentEmbed {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -75,6 +82,7 @@ export class ContentEmbed {
|
||||
write (encoder, offset) {
|
||||
encoding.writeVarString(encoder, JSON.stringify(this.embed))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
|
||||
@@ -19,30 +19,35 @@ export class ContentFormat {
|
||||
this.key = key
|
||||
this.value = value
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentFormat}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentFormat(this.key, this.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentFormat}
|
||||
@@ -50,6 +55,7 @@ export class ContentFormat {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentFormat} right
|
||||
* @return {boolean}
|
||||
@@ -57,6 +63,7 @@ export class ContentFormat {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -78,6 +85,7 @@ export class ContentFormat {
|
||||
encoding.writeVarString(encoder, this.key)
|
||||
encoding.writeVarString(encoder, JSON.stringify(this.value))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -87,8 +95,6 @@ export class ContentFormat {
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {ContentFormat}
|
||||
*/
|
||||
|
||||
@@ -18,30 +18,35 @@ export class ContentJSON {
|
||||
*/
|
||||
this.arr = arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return this.arr.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return this.arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentJSON}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentJSON(this.arr)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentJSON}
|
||||
@@ -51,6 +56,7 @@ export class ContentJSON {
|
||||
this.arr = this.arr.slice(0, offset)
|
||||
return right
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentJSON} right
|
||||
* @return {boolean}
|
||||
@@ -59,6 +65,7 @@ export class ContentJSON {
|
||||
this.arr = this.arr.concat(right.arr)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -84,6 +91,7 @@ export class ContentJSON {
|
||||
encoding.writeVarString(encoder, c === undefined ? 'undefined' : JSON.stringify(c))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
|
||||
@@ -18,30 +18,35 @@ export class ContentString {
|
||||
*/
|
||||
this.str = str
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return this.str.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return this.str.split('')
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentString}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentString(this.str)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentString}
|
||||
@@ -51,6 +56,7 @@ export class ContentString {
|
||||
this.str = this.str.slice(0, offset)
|
||||
return right
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentString} right
|
||||
* @return {boolean}
|
||||
@@ -59,6 +65,7 @@ export class ContentString {
|
||||
this.str += right.str
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -79,6 +86,7 @@ export class ContentString {
|
||||
write (encoder, offset) {
|
||||
encoding.writeVarString(encoder, offset === 0 ? this.str : this.str.slice(offset))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
readYXmlFragment,
|
||||
readYXmlHook,
|
||||
readYXmlText,
|
||||
StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
|
||||
ID, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
@@ -49,30 +49,35 @@ export class ContentType {
|
||||
*/
|
||||
this.type = type
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getLength () {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
return [this.type]
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
isCountable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ContentType}
|
||||
*/
|
||||
copy () {
|
||||
return new ContentType(this.type._copy())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {ContentType}
|
||||
@@ -80,6 +85,7 @@ export class ContentType {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContentType} right
|
||||
* @return {boolean}
|
||||
@@ -87,6 +93,7 @@ export class ContentType {
|
||||
mergeWith (right) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -94,6 +101,7 @@ export class ContentType {
|
||||
integrate (transaction, item) {
|
||||
this.type._integrate(transaction.doc, item)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
@@ -107,7 +115,7 @@ export class ContentType {
|
||||
// We try to merge all deleted items after each transaction,
|
||||
// but we have no knowledge about that this needs to be merged
|
||||
// since it is not in transaction.ds. Hence we add it to transaction._mergeStructs
|
||||
transaction._mergeStructs.add(item.id)
|
||||
transaction._mergeStructs.push(item)
|
||||
}
|
||||
item = item.right
|
||||
}
|
||||
@@ -116,12 +124,12 @@ export class ContentType {
|
||||
item.delete(transaction)
|
||||
} else {
|
||||
// same as above
|
||||
transaction._mergeStructs.add(item.id)
|
||||
transaction._mergeStructs.push(item)
|
||||
}
|
||||
})
|
||||
transaction.changed.delete(this.type)
|
||||
transaction.changedParentTypes.delete(this.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
@@ -140,6 +148,7 @@ export class ContentType {
|
||||
})
|
||||
this.type._map = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {number} offset
|
||||
@@ -147,6 +156,7 @@ export class ContentType {
|
||||
write (encoder, offset) {
|
||||
this.type._write(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
|
||||
import {
|
||||
AbstractStructRef,
|
||||
AbstractStruct,
|
||||
createID,
|
||||
addStruct,
|
||||
StructStore, Transaction, ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
|
||||
export const structGCRefNumber = 0
|
||||
@@ -38,8 +35,13 @@ export class GC extends AbstractStruct {
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {number} offset
|
||||
*/
|
||||
integrate (transaction) {
|
||||
integrate (transaction, offset) {
|
||||
if (offset > 0) {
|
||||
this.id.clock += offset
|
||||
this.length -= offset
|
||||
}
|
||||
addStruct(transaction.doc.store, this)
|
||||
}
|
||||
|
||||
@@ -51,39 +53,13 @@ export class GC extends AbstractStruct {
|
||||
encoding.writeUint8(encoder, structGCRefNumber)
|
||||
encoding.writeVarUint(encoder, this.length - offset)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export class GCRef extends AbstractStructRef {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {ID} id
|
||||
* @param {number} info
|
||||
*/
|
||||
constructor (decoder, id, info) {
|
||||
super(id)
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.length = decoding.readVarUint(decoder)
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {number} offset
|
||||
* @return {GC}
|
||||
* @return {null | ID}
|
||||
*/
|
||||
toStruct (transaction, store, offset) {
|
||||
if (offset > 0) {
|
||||
// @ts-ignore
|
||||
this.id = createID(this.id.client, this.id.clock + offset)
|
||||
this.length -= offset
|
||||
}
|
||||
return new GC(
|
||||
this.id,
|
||||
this.length
|
||||
)
|
||||
getMissing (transaction, store) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
|
||||
import {
|
||||
readID,
|
||||
createID,
|
||||
writeID,
|
||||
GC,
|
||||
nextID,
|
||||
AbstractStructRef,
|
||||
getState,
|
||||
AbstractStruct,
|
||||
replaceStruct,
|
||||
addStruct,
|
||||
@@ -18,11 +16,14 @@ import {
|
||||
readContentDeleted,
|
||||
readContentBinary,
|
||||
readContentJSON,
|
||||
readContentAny,
|
||||
readContentString,
|
||||
readContentEmbed,
|
||||
createID,
|
||||
readContentFormat,
|
||||
readContentType,
|
||||
ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
|
||||
addChangedTypeToTransaction,
|
||||
Doc, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as error from 'lib0/error.js'
|
||||
@@ -32,6 +33,49 @@ import * as maplib from 'lib0/map.js'
|
||||
import * as set from 'lib0/set.js'
|
||||
import * as binary from 'lib0/binary.js'
|
||||
|
||||
/**
|
||||
* @todo This should return several items
|
||||
*
|
||||
* @param {StructStore} store
|
||||
* @param {ID} id
|
||||
* @return {{item:Item, diff:number}}
|
||||
*/
|
||||
export const followRedone = (store, id) => {
|
||||
/**
|
||||
* @type {ID|null}
|
||||
*/
|
||||
let nextID = id
|
||||
let diff = 0
|
||||
let item
|
||||
do {
|
||||
if (diff > 0) {
|
||||
nextID = createID(nextID.client, nextID.clock + diff)
|
||||
}
|
||||
item = getItem(store, nextID)
|
||||
diff = nextID.clock - item.id.clock
|
||||
nextID = item.redone
|
||||
} while (nextID !== null && item instanceof Item)
|
||||
return {
|
||||
item, diff
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that neither item nor any of its parents is ever deleted.
|
||||
*
|
||||
* This property does not persist when storing it into a database or when
|
||||
* sending it to other peers
|
||||
*
|
||||
* @param {Item|null} item
|
||||
* @param {boolean} keep
|
||||
*/
|
||||
export const keepItem = (item, keep) => {
|
||||
while (item !== null && item.keep !== keep) {
|
||||
item.keep = keep
|
||||
item = /** @type {AbstractType<any>} */ (item.parent)._item
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split leftItem into two items
|
||||
* @param {Transaction} transaction
|
||||
@@ -43,12 +87,12 @@ import * as binary from 'lib0/binary.js'
|
||||
* @private
|
||||
*/
|
||||
export const splitItem = (transaction, leftItem, diff) => {
|
||||
const id = leftItem.id
|
||||
// create rightItem
|
||||
const { client, clock } = leftItem.id
|
||||
const rightItem = new Item(
|
||||
createID(id.client, id.clock + diff),
|
||||
createID(client, clock + diff),
|
||||
leftItem,
|
||||
createID(id.client, id.clock + diff - 1),
|
||||
createID(client, clock + diff - 1),
|
||||
leftItem.right,
|
||||
leftItem.rightOrigin,
|
||||
leftItem.parent,
|
||||
@@ -58,6 +102,12 @@ export const splitItem = (transaction, leftItem, diff) => {
|
||||
if (leftItem.deleted) {
|
||||
rightItem.deleted = true
|
||||
}
|
||||
if (leftItem.keep) {
|
||||
rightItem.keep = true
|
||||
}
|
||||
if (leftItem.redone !== null) {
|
||||
rightItem.redone = createID(leftItem.redone.client, leftItem.redone.clock + diff)
|
||||
}
|
||||
// update left (do not set leftItem.rightOrigin as it will lead to problems when syncing)
|
||||
leftItem.right = rightItem
|
||||
// update right
|
||||
@@ -65,15 +115,122 @@ export const splitItem = (transaction, leftItem, diff) => {
|
||||
rightItem.right.left = rightItem
|
||||
}
|
||||
// right is more specific.
|
||||
transaction._mergeStructs.add(rightItem.id)
|
||||
transaction._mergeStructs.push(rightItem)
|
||||
// update parent._map
|
||||
if (rightItem.parentSub !== null && rightItem.right === null) {
|
||||
rightItem.parent._map.set(rightItem.parentSub, rightItem)
|
||||
/** @type {AbstractType<any>} */ (rightItem.parent)._map.set(rightItem.parentSub, rightItem)
|
||||
}
|
||||
leftItem.length = diff
|
||||
return rightItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Redoes the effect of this operation.
|
||||
*
|
||||
* @param {Transaction} transaction The Yjs instance.
|
||||
* @param {Item} item
|
||||
* @param {Set<Item>} redoitems
|
||||
*
|
||||
* @return {Item|null}
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const redoItem = (transaction, item, redoitems) => {
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ownClientID = doc.clientID
|
||||
const redone = item.redone
|
||||
if (redone !== null) {
|
||||
return getItemCleanStart(transaction, redone)
|
||||
}
|
||||
let parentItem = /** @type {AbstractType<any>} */ (item.parent)._item
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let left
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let right
|
||||
if (item.parentSub === null) {
|
||||
// Is an array item. Insert at the old position
|
||||
left = item.left
|
||||
right = item
|
||||
} else {
|
||||
// Is a map item. Insert as current value
|
||||
left = item
|
||||
while (left.right !== null) {
|
||||
left = left.right
|
||||
if (left.id.client !== ownClientID) {
|
||||
// It is not possible to redo this item because it conflicts with a
|
||||
// change from another client
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (left.right !== null) {
|
||||
left = /** @type {Item} */ (/** @type {AbstractType<any>} */ (item.parent)._map.get(item.parentSub))
|
||||
}
|
||||
right = null
|
||||
}
|
||||
// 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) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (parentItem !== null && parentItem.redone !== null) {
|
||||
while (parentItem.redone !== null) {
|
||||
parentItem = getItemCleanStart(transaction, parentItem.redone)
|
||||
}
|
||||
// find next cloned_redo items
|
||||
while (left !== null) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let leftTrace = left
|
||||
// trace redone until parent matches
|
||||
while (leftTrace !== null && /** @type {AbstractType<any>} */ (leftTrace.parent)._item !== parentItem) {
|
||||
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
|
||||
}
|
||||
if (leftTrace !== null && /** @type {AbstractType<any>} */ (leftTrace.parent)._item === parentItem) {
|
||||
left = leftTrace
|
||||
break
|
||||
}
|
||||
left = left.left
|
||||
}
|
||||
while (right !== null) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let rightTrace = right
|
||||
// trace redone until parent matches
|
||||
while (rightTrace !== null && /** @type {AbstractType<any>} */ (rightTrace.parent)._item !== parentItem) {
|
||||
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
|
||||
}
|
||||
if (rightTrace !== null && /** @type {AbstractType<any>} */ (rightTrace.parent)._item === parentItem) {
|
||||
right = rightTrace
|
||||
break
|
||||
}
|
||||
right = right.right
|
||||
}
|
||||
}
|
||||
const nextClock = getState(store, ownClientID)
|
||||
const nextId = createID(ownClientID, nextClock)
|
||||
const redoneItem = new Item(
|
||||
nextId,
|
||||
left, left && left.lastId,
|
||||
right, right && right.id,
|
||||
parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type,
|
||||
item.parentSub,
|
||||
item.content.copy()
|
||||
)
|
||||
item.redone = nextId
|
||||
keepItem(redoneItem, true)
|
||||
redoneItem.integrate(transaction, 0)
|
||||
return redoneItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class that represents any content.
|
||||
*/
|
||||
@@ -84,7 +241,7 @@ export class Item extends AbstractStruct {
|
||||
* @param {ID | null} origin
|
||||
* @param {Item | null} right
|
||||
* @param {ID | null} rightOrigin
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {AbstractType<any>|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it.
|
||||
* @param {string | null} parentSub
|
||||
* @param {AbstractContent} content
|
||||
*/
|
||||
@@ -93,7 +250,6 @@ export class Item extends AbstractStruct {
|
||||
/**
|
||||
* The item that was originally to the left of this item.
|
||||
* @type {ID | null}
|
||||
* @readonly
|
||||
*/
|
||||
this.origin = origin
|
||||
/**
|
||||
@@ -108,14 +264,11 @@ export class Item extends AbstractStruct {
|
||||
this.right = right
|
||||
/**
|
||||
* The item that was originally to the right of this item.
|
||||
* @readonly
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.rightOrigin = rightOrigin
|
||||
/**
|
||||
* The parent type.
|
||||
* @type {AbstractType<any>}
|
||||
* @readonly
|
||||
* @type {AbstractType<any>|ID|null}
|
||||
*/
|
||||
this.parent = parent
|
||||
/**
|
||||
@@ -124,7 +277,6 @@ export class Item extends AbstractStruct {
|
||||
* to insert this item. If `parentSub = null` type._start is the list in
|
||||
* which to insert to. Otherwise it is `parent._map`.
|
||||
* @type {String | null}
|
||||
* @readonly
|
||||
*/
|
||||
this.parentSub = parentSub
|
||||
/**
|
||||
@@ -135,118 +287,201 @@ export class Item extends AbstractStruct {
|
||||
/**
|
||||
* If this type's effect is reundone this type refers to the type that undid
|
||||
* this operation.
|
||||
* @type {Item | null}
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.redone = null
|
||||
/**
|
||||
* @type {AbstractContent}
|
||||
*/
|
||||
this.content = content
|
||||
this.length = content.getLength()
|
||||
this.countable = content.isCountable()
|
||||
/**
|
||||
* If true, do not garbage collect this Item.
|
||||
*/
|
||||
this.keep = false
|
||||
}
|
||||
|
||||
get countable () {
|
||||
return this.content.isCountable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return missing ids, or define missing items and return null.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @return {null | ID}
|
||||
*/
|
||||
getMissing (transaction, store) {
|
||||
const origin = this.origin
|
||||
const rightOrigin = this.rightOrigin
|
||||
const parent = /** @type {ID} */ (this.parent)
|
||||
|
||||
if (origin && origin.clock >= getState(store, origin.client)) {
|
||||
return this.origin
|
||||
}
|
||||
if (rightOrigin && rightOrigin.clock >= getState(store, rightOrigin.client)) {
|
||||
return this.rightOrigin
|
||||
}
|
||||
if (parent && parent.constructor === ID && parent.clock >= getState(store, parent.client)) {
|
||||
return parent
|
||||
}
|
||||
|
||||
// We have all missing ids, now find the items
|
||||
|
||||
if (origin) {
|
||||
this.left = getItemCleanEnd(transaction, store, origin)
|
||||
this.origin = this.left.lastId
|
||||
}
|
||||
if (rightOrigin) {
|
||||
this.right = getItemCleanStart(transaction, rightOrigin)
|
||||
this.rightOrigin = this.right.id
|
||||
}
|
||||
if (parent && parent.constructor === ID) {
|
||||
if (parent.clock < getState(store, parent.client)) {
|
||||
const parentItem = getItem(store, parent)
|
||||
if (parentItem.constructor === GC) {
|
||||
this.parent = null
|
||||
} else {
|
||||
this.parent = /** @type {ContentType} */ (parentItem.content).type
|
||||
}
|
||||
} else {
|
||||
return parent
|
||||
}
|
||||
}
|
||||
// only set item if this shouldn't be garbage collected
|
||||
if (!this.parent) {
|
||||
if (this.left && this.left.constructor === Item) {
|
||||
this.parent = this.left.parent
|
||||
this.parentSub = this.left.parentSub
|
||||
}
|
||||
if (this.right && this.right.constructor === Item) {
|
||||
this.parent = this.right.parent
|
||||
this.parentSub = this.right.parentSub
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @private
|
||||
* @param {number} offset
|
||||
*/
|
||||
integrate (transaction) {
|
||||
integrate (transaction, offset) {
|
||||
const store = transaction.doc.store
|
||||
const id = this.id
|
||||
const parent = this.parent
|
||||
if (offset > 0) {
|
||||
this.id.clock += offset
|
||||
this.left = getItemCleanEnd(transaction, store, createID(this.id.client, this.id.clock - 1))
|
||||
this.origin = this.left.lastId
|
||||
this.content = this.content.splice(offset)
|
||||
this.length -= offset
|
||||
}
|
||||
const parentSub = this.parentSub
|
||||
const length = this.length
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let o
|
||||
// set o to the first conflicting item
|
||||
if (this.left !== null) {
|
||||
o = this.left.right
|
||||
} else if (parentSub !== null) {
|
||||
o = parent._map.get(parentSub) || null
|
||||
while (o !== null && o.left !== null) {
|
||||
o = o.left
|
||||
}
|
||||
} else {
|
||||
o = parent._start
|
||||
}
|
||||
// TODO: use something like DeleteSet here (a tree implementation would be best)
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const conflictingItems = new Set()
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const itemsBeforeOrigin = new Set()
|
||||
// Let c in conflictingItems, b in itemsBeforeOrigin
|
||||
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
|
||||
// Note that conflictingItems is a subset of itemsBeforeOrigin
|
||||
while (o !== null && o !== this.right) {
|
||||
itemsBeforeOrigin.add(o)
|
||||
conflictingItems.add(o)
|
||||
if (compareIDs(this.origin, o.origin)) {
|
||||
// case 1
|
||||
if (o.id.client < id.client) {
|
||||
this.left = o
|
||||
conflictingItems.clear()
|
||||
}
|
||||
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(store, o.origin))) {
|
||||
// case 2
|
||||
if (o.origin === null || !conflictingItems.has(getItem(store, o.origin))) {
|
||||
this.left = o
|
||||
conflictingItems.clear()
|
||||
const parent = /** @type {AbstractType<any>|null} */ (this.parent)
|
||||
|
||||
if (parent) {
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let left = this.left
|
||||
|
||||
/**
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let o
|
||||
// set o to the first conflicting item
|
||||
if (left !== null) {
|
||||
o = left.right
|
||||
} else if (parentSub !== null) {
|
||||
o = parent._map.get(parentSub) || null
|
||||
while (o !== null && o.left !== null) {
|
||||
o = o.left
|
||||
}
|
||||
} else {
|
||||
break
|
||||
o = parent._start
|
||||
}
|
||||
o = o.right
|
||||
}
|
||||
// reconnect left/right + update parent map/start if necessary
|
||||
if (this.left !== null) {
|
||||
const right = this.left.right
|
||||
this.right = right
|
||||
this.left.right = this
|
||||
} else {
|
||||
let r
|
||||
if (parentSub !== null) {
|
||||
r = parent._map.get(parentSub) || null
|
||||
while (r !== null && r.left !== null) {
|
||||
r = r.left
|
||||
// TODO: use something like DeleteSet here (a tree implementation would be best)
|
||||
// @todo use global set definitions
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const conflictingItems = new Set()
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const itemsBeforeOrigin = new Set()
|
||||
// Let c in conflictingItems, b in itemsBeforeOrigin
|
||||
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
|
||||
// Note that conflictingItems is a subset of itemsBeforeOrigin
|
||||
while (o !== null && o !== this.right) {
|
||||
itemsBeforeOrigin.add(o)
|
||||
conflictingItems.add(o)
|
||||
if (compareIDs(this.origin, o.origin)) {
|
||||
// case 1
|
||||
if (o.id.client < this.id.client) {
|
||||
left = o
|
||||
conflictingItems.clear()
|
||||
}
|
||||
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(store, o.origin))) {
|
||||
// case 2
|
||||
if (o.origin === null || !conflictingItems.has(getItem(store, o.origin))) {
|
||||
left = o
|
||||
conflictingItems.clear()
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
o = o.right
|
||||
}
|
||||
this.left = left
|
||||
// reconnect left/right + update parent map/start if necessary
|
||||
if (left !== null) {
|
||||
const right = left.right
|
||||
this.right = right
|
||||
left.right = this
|
||||
} else {
|
||||
r = parent._start
|
||||
parent._start = this
|
||||
let r
|
||||
if (parentSub !== null) {
|
||||
r = parent._map.get(parentSub) || null
|
||||
while (r !== null && r.left !== null) {
|
||||
r = r.left
|
||||
}
|
||||
} else {
|
||||
r = parent._start
|
||||
parent._start = this
|
||||
}
|
||||
this.right = r
|
||||
}
|
||||
this.right = r
|
||||
}
|
||||
if (this.right !== null) {
|
||||
this.right.left = this
|
||||
} else if (parentSub !== null) {
|
||||
// set as current parent value if right === null and this is parentSub
|
||||
parent._map.set(parentSub, this)
|
||||
if (this.left !== null) {
|
||||
// this is the current attribute value of parent. delete right
|
||||
this.left.delete(transaction)
|
||||
if (this.right !== null) {
|
||||
this.right.left = this
|
||||
} else if (parentSub !== null) {
|
||||
// set as current parent value if right === null and this is parentSub
|
||||
parent._map.set(parentSub, this)
|
||||
if (left !== null) {
|
||||
// this is the current attribute value of parent. delete right
|
||||
left.delete(transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
// adjust length of parent
|
||||
if (parentSub === null && this.countable && !this.deleted) {
|
||||
parent._length += length
|
||||
}
|
||||
addStruct(store, this)
|
||||
this.content.integrate(transaction, this)
|
||||
maplib.setIfUndefined(transaction.changed, parent, set.create).add(parentSub)
|
||||
if ((parent._item !== null && parent._item.deleted) || (this.right !== null && parentSub !== null)) {
|
||||
// delete if parent is deleted or if this is not the current attribute value of parent
|
||||
this.delete(transaction)
|
||||
// adjust length of parent
|
||||
if (parentSub === null && this.countable && !this.deleted) {
|
||||
parent._length += length
|
||||
}
|
||||
addStruct(store, this)
|
||||
this.content.integrate(transaction, this)
|
||||
// add parent to transaction.changed
|
||||
addChangedTypeToTransaction(transaction, parent, parentSub)
|
||||
if ((parent._item !== null && parent._item.deleted) || (this.right !== null && parentSub !== null)) {
|
||||
// delete if parent is deleted or if this is not the current attribute value of parent
|
||||
this.delete(transaction)
|
||||
}
|
||||
} else {
|
||||
// parent is not defined. Integrate GC struct instead
|
||||
new GC(this.id, this.length).integrate(transaction, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next non-deleted item
|
||||
* @private
|
||||
*/
|
||||
get next () {
|
||||
let n = this.right
|
||||
@@ -258,7 +493,6 @@ export class Item extends AbstractStruct {
|
||||
|
||||
/**
|
||||
* Returns the previous non-deleted item
|
||||
* @private
|
||||
*/
|
||||
get prev () {
|
||||
let n = this.left
|
||||
@@ -268,72 +502,14 @@ export class Item extends AbstractStruct {
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Redoes the effect of this operation.
|
||||
*
|
||||
* @param {Transaction} transaction The Yjs instance.
|
||||
* @param {Set<Item>} redoitems
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
redo (transaction, redoitems) {
|
||||
if (this.redone !== null) {
|
||||
return this.redone
|
||||
}
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let parent = this.parent
|
||||
if (parent === null) {
|
||||
return
|
||||
}
|
||||
let left, right
|
||||
if (this.parentSub === null) {
|
||||
// Is an array item. Insert at the old position
|
||||
left = this.left
|
||||
right = this
|
||||
} else {
|
||||
// Is a map item. Insert as current value
|
||||
left = parent.type._map.get(this.parentSub)
|
||||
right = null
|
||||
}
|
||||
// make sure that parent is redone
|
||||
if (parent._deleted === true && parent.redone === null) {
|
||||
// try to undo parent if it will be undone anyway
|
||||
if (!redoitems.has(parent) || !parent.redo(transaction, redoitems)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (parent.redone !== null) {
|
||||
while (parent.redone !== null) {
|
||||
parent = parent.redone
|
||||
}
|
||||
// find next cloned_redo items
|
||||
while (left !== null) {
|
||||
if (left.redone !== null && left.redone.parent === parent) {
|
||||
left = left.redone
|
||||
break
|
||||
}
|
||||
left = left.left
|
||||
}
|
||||
while (right !== null) {
|
||||
if (right.redone !== null && right.redone.parent === parent) {
|
||||
right = right.redone
|
||||
}
|
||||
right = right.right
|
||||
}
|
||||
}
|
||||
this.redone = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, this.parentSub, this.content.copy())
|
||||
this.redone.integrate(transaction)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the last content address of this Item.
|
||||
*/
|
||||
get lastId () {
|
||||
return createID(this.id.client, this.id.clock + this.length - 1)
|
||||
// allocating ids is pretty costly because of the amount of ids created, so we try to reuse whenever possible
|
||||
return this.length === 1 ? this.id : createID(this.id.client, this.id.clock + this.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to merge two items
|
||||
*
|
||||
@@ -348,9 +524,14 @@ export class Item extends AbstractStruct {
|
||||
this.id.client === right.id.client &&
|
||||
this.id.clock + this.length === right.id.clock &&
|
||||
this.deleted === right.deleted &&
|
||||
this.redone === null &&
|
||||
right.redone === null &&
|
||||
this.content.constructor === right.content.constructor &&
|
||||
this.content.mergeWith(right.content)
|
||||
) {
|
||||
if (right.keep) {
|
||||
this.keep = true
|
||||
}
|
||||
this.right = right.right
|
||||
if (this.right !== null) {
|
||||
this.right.left = this
|
||||
@@ -368,7 +549,7 @@ export class Item extends AbstractStruct {
|
||||
*/
|
||||
delete (transaction) {
|
||||
if (!this.deleted) {
|
||||
const parent = this.parent
|
||||
const parent = /** @type {AbstractType<any>} */ (this.parent)
|
||||
// adjust the length of parent
|
||||
if (this.countable && this.parentSub === null) {
|
||||
parent._length -= this.length
|
||||
@@ -383,8 +564,6 @@ export class Item extends AbstractStruct {
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
* @param {boolean} parentGCd
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
gc (store, parentGCd) {
|
||||
if (!this.deleted) {
|
||||
@@ -406,8 +585,6 @@ export class Item extends AbstractStruct {
|
||||
*
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
* @param {number} offset
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
const origin = offset > 0 ? createID(this.id.client, this.id.clock + offset - 1) : this.origin
|
||||
@@ -425,8 +602,9 @@ export class Item extends AbstractStruct {
|
||||
writeID(encoder, rightOrigin)
|
||||
}
|
||||
if (origin === null && rightOrigin === null) {
|
||||
const parent = this.parent
|
||||
if (parent._item === null) {
|
||||
const parent = /** @type {AbstractType<any>} */ (this.parent)
|
||||
const parentItem = parent._item
|
||||
if (parentItem === null) {
|
||||
// parent type on y._map
|
||||
// find the correct key
|
||||
const ykey = findRootTypeKey(parent)
|
||||
@@ -434,7 +612,7 @@ export class Item extends AbstractStruct {
|
||||
encoding.writeVarString(encoder, ykey)
|
||||
} else {
|
||||
encoding.writeVarUint(encoder, 0) // write parent id
|
||||
writeID(encoder, parent._item.id)
|
||||
writeID(encoder, parentItem.id)
|
||||
}
|
||||
if (parentSub !== null) {
|
||||
encoding.writeVarString(encoder, parentSub)
|
||||
@@ -463,7 +641,8 @@ export const contentRefs = [
|
||||
readContentString,
|
||||
readContentEmbed,
|
||||
readContentFormat,
|
||||
readContentType
|
||||
readContentType,
|
||||
readContentAny
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -476,12 +655,14 @@ export class AbstractContent {
|
||||
getLength () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<any>}
|
||||
*/
|
||||
getContent () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return false if this Item is some kind of meta information
|
||||
* (e.g. format information).
|
||||
@@ -494,12 +675,14 @@ export class AbstractContent {
|
||||
isCountable () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {AbstractContent}
|
||||
*/
|
||||
copy () {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @return {AbstractContent}
|
||||
@@ -507,6 +690,7 @@ export class AbstractContent {
|
||||
splice (offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractContent} right
|
||||
* @return {boolean}
|
||||
@@ -514,6 +698,7 @@ export class AbstractContent {
|
||||
mergeWith (right) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} item
|
||||
@@ -521,18 +706,21 @@ export class AbstractContent {
|
||||
integrate (transaction, item) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
delete (transaction) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
gc (store) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {number} offset
|
||||
@@ -540,6 +728,7 @@ export class AbstractContent {
|
||||
write (encoder, offset) {
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
@@ -549,121 +738,49 @@ export class AbstractContent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {ID} id
|
||||
* @param {number} info
|
||||
* @param {Doc} doc
|
||||
*/
|
||||
export class ItemRef extends AbstractStructRef {
|
||||
export const readItem = (decoder, id, info, doc) => {
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {ID} id
|
||||
* @param {number} info
|
||||
* The item that was originally to the left of this item.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
constructor (decoder, id, info) {
|
||||
super(id)
|
||||
/**
|
||||
* The item that was originally to the left of this item.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.left = (info & binary.BIT8) === binary.BIT8 ? readID(decoder) : null
|
||||
/**
|
||||
* The item that was originally to the right of this item.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.right = (info & binary.BIT7) === binary.BIT7 ? readID(decoder) : null
|
||||
const canCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||
const hasParentYKey = canCopyParentInfo ? decoding.readVarUint(decoder) === 1 : false
|
||||
/**
|
||||
* If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||
* and we read the next string as parentYKey.
|
||||
* It indicates how we store/retrieve parent from `y.share`
|
||||
* @type {string|null}
|
||||
*/
|
||||
this.parentYKey = canCopyParentInfo && hasParentYKey ? decoding.readVarString(decoder) : null
|
||||
/**
|
||||
* The parent type.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
this.parent = canCopyParentInfo && !hasParentYKey ? readID(decoder) : null
|
||||
/**
|
||||
* If the parent refers to this item with some kind of key (e.g. YMap, the
|
||||
* key is specified here. The key is then used to refer to the list in which
|
||||
* to insert this item. If `parentSub = null` type._start is the list in
|
||||
* which to insert to. Otherwise it is `parent._map`.
|
||||
* @type {String | null}
|
||||
*/
|
||||
this.parentSub = canCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoding.readVarString(decoder) : null
|
||||
const missing = this._missing
|
||||
if (this.left !== null) {
|
||||
missing.push(this.left)
|
||||
}
|
||||
if (this.right !== null) {
|
||||
missing.push(this.right)
|
||||
}
|
||||
if (this.parent !== null) {
|
||||
missing.push(this.parent)
|
||||
}
|
||||
/**
|
||||
* @type {AbstractContent}
|
||||
*/
|
||||
this.content = readItemContent(decoder, info)
|
||||
this.length = this.content.getLength()
|
||||
}
|
||||
const origin = (info & binary.BIT8) === binary.BIT8 ? readID(decoder) : null
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {number} offset
|
||||
* @return {Item|GC}
|
||||
* The item that was originally to the right of this item.
|
||||
* @type {ID | null}
|
||||
*/
|
||||
toStruct (transaction, store, offset) {
|
||||
if (offset > 0) {
|
||||
/**
|
||||
* @type {ID}
|
||||
*/
|
||||
const id = this.id
|
||||
this.id = createID(id.client, id.clock + offset)
|
||||
this.left = createID(this.id.client, this.id.clock - 1)
|
||||
this.content = this.content.splice(offset)
|
||||
}
|
||||
const rightOrigin = (info & binary.BIT7) === binary.BIT7 ? readID(decoder) : null
|
||||
const canCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||
const hasParentYKey = canCopyParentInfo ? decoding.readVarUint(decoder) === 1 : false
|
||||
/**
|
||||
* If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||
* and we read the next string as parentYKey.
|
||||
* It indicates how we store/retrieve parent from `y.share`
|
||||
* @type {string|null}
|
||||
*/
|
||||
const parentYKey = canCopyParentInfo && hasParentYKey ? decoding.readVarString(decoder) : null
|
||||
/**
|
||||
* The parent type.
|
||||
* @type {ID | AbstractType<any> | null}
|
||||
*/
|
||||
const parent = canCopyParentInfo && !hasParentYKey ? readID(decoder) : (parentYKey ? doc.get(parentYKey) : null)
|
||||
/**
|
||||
* If the parent refers to this item with some kind of key (e.g. YMap, the
|
||||
* key is specified here. The key is then used to refer to the list in which
|
||||
* to insert this item. If `parentSub = null` type._start is the list in
|
||||
* which to insert to. Otherwise it is `parent._map`.
|
||||
* @type {String | null}
|
||||
*/
|
||||
const parentSub = canCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoding.readVarString(decoder) : null
|
||||
|
||||
const left = this.left === null ? null : getItemCleanEnd(transaction, store, this.left)
|
||||
const right = this.right === null ? null : getItemCleanStart(transaction, store, this.right)
|
||||
let parent = null
|
||||
let parentSub = this.parentSub
|
||||
if (this.parent !== null) {
|
||||
const parentItem = getItem(store, this.parent)
|
||||
// Edge case: toStruct is called with an offset > 0. In this case left is defined.
|
||||
// Depending in which order structs arrive, left may be GC'd and the parent not
|
||||
// deleted. This is why we check if left is GC'd. Strictly we don't have
|
||||
// to check if right is GC'd, but we will in case we run into future issues
|
||||
if (!parentItem.deleted && (left === null || left.constructor !== GC) && (right === null || right.constructor !== GC)) {
|
||||
parent = /** @type {ContentType} */ (parentItem.content).type
|
||||
}
|
||||
} else if (this.parentYKey !== null) {
|
||||
parent = transaction.doc.get(this.parentYKey)
|
||||
} else if (left !== null) {
|
||||
if (left.constructor !== GC) {
|
||||
parent = left.parent
|
||||
parentSub = left.parentSub
|
||||
}
|
||||
} else if (right !== null) {
|
||||
if (right.constructor !== GC) {
|
||||
parent = right.parent
|
||||
parentSub = right.parentSub
|
||||
}
|
||||
} else {
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
/**
|
||||
* @type {AbstractContent}
|
||||
*/
|
||||
const content = readItemContent(decoder, info)
|
||||
|
||||
return parent === null
|
||||
? new GC(this.id, this.length)
|
||||
: new Item(
|
||||
this.id,
|
||||
left,
|
||||
this.left,
|
||||
right,
|
||||
this.right,
|
||||
parent,
|
||||
parentSub,
|
||||
this.content
|
||||
)
|
||||
}
|
||||
return new Item(id, null, origin, null, rightOrigin, parent, parentSub, content)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ import {
|
||||
callEventHandlerListeners,
|
||||
addEventHandlerListener,
|
||||
createEventHandler,
|
||||
nextID,
|
||||
getState,
|
||||
isVisible,
|
||||
ContentType,
|
||||
ContentJSON,
|
||||
ContentBinary,
|
||||
createID,
|
||||
ContentAny,
|
||||
ContentBinary,
|
||||
getItemCleanStart,
|
||||
Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
|
||||
ID, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as map from 'lib0/map.js'
|
||||
@@ -19,10 +19,25 @@ import * as iterator from 'lib0/iterator.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
|
||||
/**
|
||||
* Accumulate all (list) children of a type and return them as an Array.
|
||||
*
|
||||
* @param {AbstractType<any>} t
|
||||
* @return {Array<Item>}
|
||||
*/
|
||||
export const getTypeChildren = t => {
|
||||
let s = t._start
|
||||
const arr = []
|
||||
while (s) {
|
||||
arr.push(s)
|
||||
s = s.right
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
/**
|
||||
* Call event listeners with an event. This will also add an event to all
|
||||
* parents (for `.observeDeep` handlers).
|
||||
* @private
|
||||
*
|
||||
* @template EventType
|
||||
* @param {AbstractType<EventType>} type
|
||||
@@ -30,7 +45,7 @@ import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
* @param {EventType} event
|
||||
*/
|
||||
export const callTypeObservers = (type, transaction, event) => {
|
||||
callEventHandlerListeners(type._eH, event, transaction)
|
||||
const changedType = type
|
||||
const changedParentTypes = transaction.changedParentTypes
|
||||
while (true) {
|
||||
// @ts-ignore
|
||||
@@ -38,8 +53,9 @@ export const callTypeObservers = (type, transaction, event) => {
|
||||
if (type._item === null) {
|
||||
break
|
||||
}
|
||||
type = type._item.parent
|
||||
type = /** @type {AbstractType<any>} */ (type._item.parent)
|
||||
}
|
||||
callEventHandlerListeners(changedType._eH, event, transaction)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,17 +69,14 @@ export class AbstractType {
|
||||
*/
|
||||
this._item = null
|
||||
/**
|
||||
* @private
|
||||
* @type {Map<string,Item>}
|
||||
*/
|
||||
this._map = new Map()
|
||||
/**
|
||||
* @private
|
||||
* @type {Item|null}
|
||||
*/
|
||||
this._start = null
|
||||
/**
|
||||
* @private
|
||||
* @type {Doc|null}
|
||||
*/
|
||||
this.doc = null
|
||||
@@ -89,7 +102,6 @@ export class AbstractType {
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item|null} item
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
this.doc = y
|
||||
@@ -98,7 +110,6 @@ export class AbstractType {
|
||||
|
||||
/**
|
||||
* @return {AbstractType<EventType>}
|
||||
* @private
|
||||
*/
|
||||
_copy () {
|
||||
throw error.methodUnimplemented()
|
||||
@@ -106,7 +117,6 @@ export class AbstractType {
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @private
|
||||
*/
|
||||
_write (encoder) { }
|
||||
|
||||
@@ -127,8 +137,6 @@ export class AbstractType {
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) { /* skip if no type is specified */ }
|
||||
|
||||
@@ -170,7 +178,7 @@ export class AbstractType {
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @return {Object | Array | number | string}
|
||||
* @return {any}
|
||||
*/
|
||||
toJSON () {}
|
||||
}
|
||||
@@ -367,15 +375,18 @@ export const typeListGet = (type, index) => {
|
||||
*/
|
||||
export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
|
||||
let left = referenceItem
|
||||
const doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
const store = doc.store
|
||||
const right = referenceItem === null ? parent._start : referenceItem.right
|
||||
/**
|
||||
* @type {Array<Object|Array|number>}
|
||||
* @type {Array<Object|Array<any>|number>}
|
||||
*/
|
||||
let jsonContent = []
|
||||
const packJsonContent = () => {
|
||||
if (jsonContent.length > 0) {
|
||||
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentJSON(jsonContent))
|
||||
left.integrate(transaction)
|
||||
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent))
|
||||
left.integrate(transaction, 0)
|
||||
jsonContent = []
|
||||
}
|
||||
}
|
||||
@@ -393,13 +404,13 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
|
||||
switch (c.constructor) {
|
||||
case Uint8Array:
|
||||
case ArrayBuffer:
|
||||
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
|
||||
left.integrate(transaction)
|
||||
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
|
||||
left.integrate(transaction, 0)
|
||||
break
|
||||
default:
|
||||
if (c instanceof AbstractType) {
|
||||
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentType(c))
|
||||
left.integrate(transaction)
|
||||
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c))
|
||||
left.integrate(transaction, 0)
|
||||
} else {
|
||||
throw new Error('Unexpected content type in insert operation')
|
||||
}
|
||||
@@ -428,7 +439,7 @@ export const typeListInsertGenerics = (transaction, parent, index, content) => {
|
||||
if (index <= n.length) {
|
||||
if (index < n.length) {
|
||||
// insert in-between
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + index))
|
||||
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -454,7 +465,7 @@ export const typeListDelete = (transaction, parent, index, length) => {
|
||||
for (; n !== null && index > 0; n = n.right) {
|
||||
if (!n.deleted && n.countable) {
|
||||
if (index < n.length) {
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + index))
|
||||
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
|
||||
}
|
||||
index -= n.length
|
||||
}
|
||||
@@ -463,7 +474,7 @@ export const typeListDelete = (transaction, parent, index, length) => {
|
||||
while (length > 0 && n !== null) {
|
||||
if (!n.deleted) {
|
||||
if (length < n.length) {
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + length))
|
||||
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length))
|
||||
}
|
||||
n.delete(transaction)
|
||||
length -= n.length
|
||||
@@ -501,9 +512,11 @@ export const typeMapDelete = (transaction, parent, key) => {
|
||||
*/
|
||||
export const typeMapSet = (transaction, parent, key, value) => {
|
||||
const left = parent._map.get(key) || null
|
||||
const doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
let content
|
||||
if (value == null) {
|
||||
content = new ContentJSON([value])
|
||||
content = new ContentAny([value])
|
||||
} else {
|
||||
switch (value.constructor) {
|
||||
case Number:
|
||||
@@ -511,10 +524,10 @@ export const typeMapSet = (transaction, parent, key, value) => {
|
||||
case Boolean:
|
||||
case Array:
|
||||
case String:
|
||||
content = new ContentJSON([value])
|
||||
content = new ContentAny([value])
|
||||
break
|
||||
case Uint8Array:
|
||||
content = new ContentBinary(value)
|
||||
content = new ContentBinary(/** @type {Uint8Array} */ (value))
|
||||
break
|
||||
default:
|
||||
if (value instanceof AbstractType) {
|
||||
@@ -524,7 +537,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
new Item(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, content).integrate(transaction)
|
||||
new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -551,7 +564,7 @@ export const typeMapGetAll = (parent) => {
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
let res = {}
|
||||
const res = {}
|
||||
for (const [key, value] of parent._map) {
|
||||
if (!value.deleted) {
|
||||
res[key] = value.content.getContent()[value.length - 1]
|
||||
@@ -584,7 +597,7 @@ export const typeMapHas = (parent, key) => {
|
||||
*/
|
||||
export const typeMapGetSnapshot = (parent, key, snapshot) => {
|
||||
let v = parent._map.get(key) || null
|
||||
while (v !== null && (!snapshot.sm.has(v.id.client) || v.id.clock >= (snapshot.sm.get(v.id.client) || 0))) {
|
||||
while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) {
|
||||
v = v.left
|
||||
}
|
||||
return v !== null && isVisible(v, snapshot) ? v.content.getContent()[v.length - 1] : undefined
|
||||
|
||||
@@ -40,7 +40,7 @@ export class YArrayEvent extends YEvent {
|
||||
* A shared Array implementation.
|
||||
* @template T
|
||||
* @extends AbstractType<YArrayEvent<T>>
|
||||
* @implements {IterableIterator<T>}
|
||||
* @implements {Iterable<T>}
|
||||
*/
|
||||
export class YArray extends AbstractType {
|
||||
constructor () {
|
||||
@@ -51,6 +51,7 @@ export class YArray extends AbstractType {
|
||||
*/
|
||||
this._prelimContent = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrate this type into the Yjs instance.
|
||||
*
|
||||
@@ -60,24 +61,26 @@ export class YArray extends AbstractType {
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
this.insert(0, /** @type {Array} */ (this._prelimContent))
|
||||
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
_copy () {
|
||||
return new YArray()
|
||||
}
|
||||
|
||||
get length () {
|
||||
return this._prelimContent === null ? this._length : this._prelimContent.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates YArrayEvent and calls observers.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
callTypeObservers(this, transaction, new YArrayEvent(this, transaction))
|
||||
@@ -105,7 +108,7 @@ export class YArray extends AbstractType {
|
||||
typeListInsertGenerics(transaction, this, index, content)
|
||||
})
|
||||
} else {
|
||||
/** @type {Array} */ (this._prelimContent).splice(index, 0, ...content)
|
||||
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +121,15 @@ export class YArray extends AbstractType {
|
||||
this.insert(this.length, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preppends content to this YArray.
|
||||
*
|
||||
* @param {Array<T>} content Array of content to preppend.
|
||||
*/
|
||||
unshift (content) {
|
||||
this.insert(0, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes elements starting from an index.
|
||||
*
|
||||
@@ -130,7 +142,7 @@ export class YArray extends AbstractType {
|
||||
typeListDelete(transaction, this, index, length)
|
||||
})
|
||||
} else {
|
||||
/** @type {Array} */ (this._prelimContent).splice(index, length)
|
||||
/** @type {Array<any>} */ (this._prelimContent).splice(index, length)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +205,6 @@ export class YArray extends AbstractType {
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @private
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoding.writeVarUint(encoder, YArrayRefID)
|
||||
|
||||
@@ -42,17 +42,28 @@ export class YMapEvent extends YEvent {
|
||||
* A shared Map implementation.
|
||||
*
|
||||
* @extends AbstractType<YMapEvent<T>>
|
||||
* @implements {IterableIterator}
|
||||
* @implements {Iterable<T>}
|
||||
*/
|
||||
export class YMap extends AbstractType {
|
||||
constructor () {
|
||||
/**
|
||||
*
|
||||
* @param {Iterable<readonly [string, any]>=} entries - an optional iterable to initialize the YMap
|
||||
*/
|
||||
constructor (entries) {
|
||||
super()
|
||||
/**
|
||||
* @type {Map<string,any>?}
|
||||
* @private
|
||||
*/
|
||||
this._prelimContent = new Map()
|
||||
this._prelimContent = null
|
||||
|
||||
if (entries === undefined) {
|
||||
this._prelimContent = new Map()
|
||||
} else {
|
||||
this._prelimContent = new Map(entries)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrate this type into the Yjs instance.
|
||||
*
|
||||
@@ -62,23 +73,24 @@ export class YMap extends AbstractType {
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
for (let [key, value] of /** @type {Map<string, any>} */ (this._prelimContent)) {
|
||||
for (const [key, value] of /** @type {Map<string, any>} */ (this._prelimContent)) {
|
||||
this.set(key, value)
|
||||
}
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
_copy () {
|
||||
return new YMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates YMapEvent and calls observers.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
callTypeObservers(this, transaction, new YMapEvent(this, transaction, parentSubs))
|
||||
@@ -94,7 +106,7 @@ export class YMap extends AbstractType {
|
||||
* @type {Object<string,T>}
|
||||
*/
|
||||
const map = {}
|
||||
for (let [key, item] of this._map) {
|
||||
for (const [key, item] of this._map) {
|
||||
if (!item.deleted) {
|
||||
const v = item.content.getContent()[item.length - 1]
|
||||
map[key] = v instanceof AbstractType ? v.toJSON() : v
|
||||
@@ -103,10 +115,19 @@ export class YMap extends AbstractType {
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the YMap (count of key/value pairs)
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
get size () {
|
||||
return [...createMapIterator(this._map)].length
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the keys for each element in the YMap Type.
|
||||
*
|
||||
* @return {Iterator<string>}
|
||||
* @return {IterableIterator<string>}
|
||||
*/
|
||||
keys () {
|
||||
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[0])
|
||||
@@ -115,7 +136,7 @@ export class YMap extends AbstractType {
|
||||
/**
|
||||
* Returns the keys for each element in the YMap Type.
|
||||
*
|
||||
* @return {Iterator<string>}
|
||||
* @return {IterableIterator<string>}
|
||||
*/
|
||||
values () {
|
||||
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])
|
||||
@@ -130,6 +151,24 @@ export class YMap extends AbstractType {
|
||||
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => [v[0], v[1].content.getContent()[v[1].length - 1]])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
forEach (f) {
|
||||
/**
|
||||
* @type {Object<string,T>}
|
||||
*/
|
||||
const map = {}
|
||||
for (const [key, item] of this._map) {
|
||||
if (!item.deleted) {
|
||||
f(item.content.getContent()[item.length - 1], key, this)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {IterableIterator<T>}
|
||||
*/
|
||||
@@ -191,8 +230,6 @@ export class YMap extends AbstractType {
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoding.writeVarUint(encoder, YMapRefID)
|
||||
|
||||
@@ -6,21 +6,34 @@
|
||||
import {
|
||||
YEvent,
|
||||
AbstractType,
|
||||
nextID,
|
||||
createID,
|
||||
getItemCleanStart,
|
||||
getState,
|
||||
isVisible,
|
||||
createID,
|
||||
YTextRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
ContentEmbed,
|
||||
GC,
|
||||
ContentFormat,
|
||||
ContentString,
|
||||
Doc, Item, Snapshot, StructStore, Transaction // eslint-disable-line
|
||||
splitSnapshotAffectedStructs,
|
||||
iterateDeletedStructs,
|
||||
iterateStructs,
|
||||
ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as object from 'lib0/object.js'
|
||||
import * as map from 'lib0/map.js'
|
||||
|
||||
/**
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @return {boolean}
|
||||
*/
|
||||
const equalAttrs = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))
|
||||
|
||||
export class ItemListPosition {
|
||||
/**
|
||||
@@ -59,7 +72,6 @@ export class ItemInsertionResult extends ItemListPosition {
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {Map<string,any>} currentAttributes
|
||||
* @param {Item|null} left
|
||||
* @param {Item|null} right
|
||||
@@ -69,7 +81,7 @@ export class ItemInsertionResult extends ItemListPosition {
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const findNextPosition = (transaction, store, currentAttributes, left, right, count) => {
|
||||
const findNextPosition = (transaction, currentAttributes, left, right, count) => {
|
||||
while (right !== null && count > 0) {
|
||||
switch (right.content.constructor) {
|
||||
case ContentEmbed:
|
||||
@@ -77,7 +89,7 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
|
||||
if (!right.deleted) {
|
||||
if (count < right.length) {
|
||||
// split right
|
||||
getItemCleanStart(transaction, store, createID(right.id.client, right.id.clock + count))
|
||||
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + count))
|
||||
}
|
||||
count -= right.length
|
||||
}
|
||||
@@ -96,7 +108,6 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {number} index
|
||||
* @return {ItemTextListPosition}
|
||||
@@ -104,11 +115,10 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const findPosition = (transaction, store, parent, index) => {
|
||||
let currentAttributes = new Map()
|
||||
let left = null
|
||||
let right = parent._start
|
||||
return findNextPosition(transaction, store, currentAttributes, left, right, index)
|
||||
const findPosition = (transaction, parent, index) => {
|
||||
const currentAttributes = new Map()
|
||||
const right = parent._start
|
||||
return findNextPosition(transaction, currentAttributes, null, right, index)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,21 +126,20 @@ const findPosition = (transaction, store, parent, index) => {
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {Item|null} left
|
||||
* @param {Item|null} right
|
||||
* @param {ItemListPosition} currPos
|
||||
* @param {Map<string,any>} negatedAttributes
|
||||
* @return {ItemListPosition}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const insertNegatedAttributes = (transaction, parent, left, right, negatedAttributes) => {
|
||||
const insertNegatedAttributes = (transaction, parent, currPos, negatedAttributes) => {
|
||||
let { left, right } = currPos
|
||||
// check if we really need to remove attributes
|
||||
while (
|
||||
right !== null && (
|
||||
right.deleted === true || (
|
||||
right.content.constructor === ContentFormat &&
|
||||
(negatedAttributes.get(/** @type {ContentFormat} */ (right.content).key) === /** @type {ContentFormat} */ (right.content).value)
|
||||
equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (right.content).key), /** @type {ContentFormat} */ (right.content).value)
|
||||
)
|
||||
)
|
||||
) {
|
||||
@@ -140,11 +149,14 @@ const insertNegatedAttributes = (transaction, parent, left, right, negatedAttrib
|
||||
left = right
|
||||
right = right.right
|
||||
}
|
||||
for (let [key, val] of negatedAttributes) {
|
||||
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val))
|
||||
left.integrate(transaction)
|
||||
const doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
for (const [key, val] of negatedAttributes) {
|
||||
left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
|
||||
left.integrate(transaction, 0)
|
||||
}
|
||||
return { left, right }
|
||||
currPos.left = left
|
||||
currPos.right = right
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,23 +176,22 @@ const updateCurrentAttributes = (currentAttributes, format) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item|null} left
|
||||
* @param {Item|null} right
|
||||
* @param {ItemListPosition} currPos
|
||||
* @param {Map<string,any>} currentAttributes
|
||||
* @param {Object<string,any>} attributes
|
||||
* @return {ItemListPosition}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const minimizeAttributeChanges = (left, right, currentAttributes, attributes) => {
|
||||
const minimizeAttributeChanges = (currPos, currentAttributes, attributes) => {
|
||||
// go right while attributes[right.key] === right.value (or right is deleted)
|
||||
let { left, right } = currPos
|
||||
while (true) {
|
||||
if (right === null) {
|
||||
break
|
||||
} else if (right.deleted) {
|
||||
// continue
|
||||
} else if (right.content.constructor === ContentFormat && (attributes[(/** @type {ContentFormat} */ (right.content)).key] || null) === /** @type {ContentFormat} */ (right.content).value) {
|
||||
} else if (right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (right.content)).key] || null, /** @type {ContentFormat} */ (right.content).value)) {
|
||||
// found a format, update currentAttributes and continue
|
||||
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
|
||||
} else {
|
||||
@@ -189,96 +200,96 @@ const minimizeAttributeChanges = (left, right, currentAttributes, attributes) =>
|
||||
left = right
|
||||
right = right.right
|
||||
}
|
||||
return new ItemListPosition(left, right)
|
||||
currPos.left = left
|
||||
currPos.right = right
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {Item|null} left
|
||||
* @param {Item|null} right
|
||||
* @param {ItemListPosition} currPos
|
||||
* @param {Map<string,any>} currentAttributes
|
||||
* @param {Object<string,any>} attributes
|
||||
* @return {ItemInsertionResult}
|
||||
* @return {Map<string,any>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
**/
|
||||
const insertAttributes = (transaction, parent, left, right, currentAttributes, attributes) => {
|
||||
const insertAttributes = (transaction, parent, currPos, currentAttributes, attributes) => {
|
||||
const doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
const negatedAttributes = new Map()
|
||||
// insert format-start items
|
||||
for (let key in attributes) {
|
||||
for (const key in attributes) {
|
||||
const val = attributes[key]
|
||||
const currentVal = currentAttributes.get(key) || null
|
||||
if (currentVal !== val) {
|
||||
if (!equalAttrs(currentVal, val)) {
|
||||
// save negated attribute (set null if currentVal undefined)
|
||||
negatedAttributes.set(key, currentVal)
|
||||
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val))
|
||||
left.integrate(transaction)
|
||||
const { left, right } = currPos
|
||||
currPos.left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val))
|
||||
currPos.left.integrate(transaction, 0)
|
||||
}
|
||||
}
|
||||
return new ItemInsertionResult(left, right, negatedAttributes)
|
||||
return negatedAttributes
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {Item|null} left
|
||||
* @param {Item|null} right
|
||||
* @param {ItemListPosition} currPos
|
||||
* @param {Map<string,any>} currentAttributes
|
||||
* @param {string} text
|
||||
* @param {string|object} text
|
||||
* @param {Object<string,any>} attributes
|
||||
* @return {ItemListPosition}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
**/
|
||||
const insertText = (transaction, parent, left, right, currentAttributes, text, attributes) => {
|
||||
for (let [key] of currentAttributes) {
|
||||
const insertText = (transaction, parent, currPos, currentAttributes, text, attributes) => {
|
||||
for (const [key] of currentAttributes) {
|
||||
if (attributes[key] === undefined) {
|
||||
attributes[key] = null
|
||||
}
|
||||
}
|
||||
const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes)
|
||||
const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes)
|
||||
left = insertPos.left
|
||||
right = insertPos.right
|
||||
const doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
minimizeAttributeChanges(currPos, currentAttributes, attributes)
|
||||
const negatedAttributes = insertAttributes(transaction, parent, currPos, currentAttributes, attributes)
|
||||
// insert content
|
||||
const content = text.constructor === String ? new ContentString(text) : new ContentEmbed(text)
|
||||
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, content)
|
||||
left.integrate(transaction)
|
||||
return insertNegatedAttributes(transaction, parent, left, insertPos.right, insertPos.negatedAttributes)
|
||||
const content = text.constructor === String ? new ContentString(/** @type {string} */ (text)) : new ContentEmbed(text)
|
||||
const { left, right } = currPos
|
||||
currPos.left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content)
|
||||
currPos.left.integrate(transaction, 0)
|
||||
return insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {Item|null} left
|
||||
* @param {Item|null} right
|
||||
* @param {ItemListPosition} currPos
|
||||
* @param {Map<string,any>} currentAttributes
|
||||
* @param {number} length
|
||||
* @param {Object<string,any>} attributes
|
||||
* @return {ItemListPosition}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const formatText = (transaction, parent, left, right, currentAttributes, length, attributes) => {
|
||||
const minPos = minimizeAttributeChanges(left, right, currentAttributes, attributes)
|
||||
const insertPos = insertAttributes(transaction, parent, minPos.left, minPos.right, currentAttributes, attributes)
|
||||
const negatedAttributes = insertPos.negatedAttributes
|
||||
left = insertPos.left
|
||||
right = insertPos.right
|
||||
const formatText = (transaction, parent, currPos, currentAttributes, length, attributes) => {
|
||||
const doc = transaction.doc
|
||||
const ownClientId = doc.clientID
|
||||
minimizeAttributeChanges(currPos, currentAttributes, attributes)
|
||||
const negatedAttributes = insertAttributes(transaction, parent, currPos, currentAttributes, attributes)
|
||||
let { left, right } = currPos
|
||||
// iterate until first non-format or null is found
|
||||
// delete all formats with attributes[format.key] != null
|
||||
while (length > 0 && right !== null) {
|
||||
if (right.deleted === false) {
|
||||
if (!right.deleted) {
|
||||
switch (right.content.constructor) {
|
||||
case ContentFormat:
|
||||
case ContentFormat: {
|
||||
const { key, value } = /** @type {ContentFormat} */ (right.content)
|
||||
const attr = attributes[key]
|
||||
if (attr !== undefined) {
|
||||
if (attr === value) {
|
||||
if (equalAttrs(attr, value)) {
|
||||
negatedAttributes.delete(key)
|
||||
} else {
|
||||
negatedAttributes.set(key, value)
|
||||
@@ -287,10 +298,11 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
|
||||
}
|
||||
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
|
||||
break
|
||||
}
|
||||
case ContentEmbed:
|
||||
case ContentString:
|
||||
if (length < right.length) {
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(right.id.client, right.id.clock + length))
|
||||
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
|
||||
}
|
||||
length -= right.length
|
||||
break
|
||||
@@ -299,13 +311,129 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
|
||||
left = right
|
||||
right = right.right
|
||||
}
|
||||
return insertNegatedAttributes(transaction, parent, left, right, negatedAttributes)
|
||||
// Quill just assumes that the editor starts with a newline and that it always
|
||||
// ends with a newline. We only insert that newline when a new newline is
|
||||
// inserted - i.e when length is bigger than type.length
|
||||
if (length > 0) {
|
||||
let newlines = ''
|
||||
for (; length > 0; length--) {
|
||||
newlines += '\n'
|
||||
}
|
||||
left = new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentString(newlines))
|
||||
left.integrate(transaction, 0)
|
||||
}
|
||||
currPos.left = left
|
||||
currPos.right = right
|
||||
insertNegatedAttributes(transaction, parent, currPos, negatedAttributes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this function after string content has been deleted in order to
|
||||
* clean up formatting Items.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item} start
|
||||
* @param {Item|null} end exclusive end, automatically iterates to the next Content Item
|
||||
* @param {Map<string,any>} startAttributes
|
||||
* @param {Map<string,any>} endAttributes This attribute is modified!
|
||||
* @return {number} The amount of formatting Items deleted.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttributes) => {
|
||||
while (end && end.content.constructor !== ContentString && end.content.constructor !== ContentEmbed) {
|
||||
if (!end.deleted && end.content.constructor === ContentFormat) {
|
||||
updateCurrentAttributes(endAttributes, /** @type {ContentFormat} */ (end.content))
|
||||
}
|
||||
end = end.right
|
||||
}
|
||||
let cleanups = 0
|
||||
while (start !== end) {
|
||||
if (!start.deleted) {
|
||||
const content = start.content
|
||||
switch (content.constructor) {
|
||||
case ContentFormat: {
|
||||
const { key, value } = /** @type {ContentFormat} */ (content)
|
||||
if ((endAttributes.get(key) || null) !== value || (startAttributes.get(key) || null) === value) {
|
||||
// Either this format is overwritten or it is not necessary because the attribute already existed.
|
||||
start.delete(transaction)
|
||||
cleanups++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
start = /** @type {Item} */ (start.right)
|
||||
}
|
||||
return cleanups
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Item|null} left
|
||||
* @param {Item|null} right
|
||||
* @param {Item | null} item
|
||||
*/
|
||||
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))) {
|
||||
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))) {
|
||||
if (!item.deleted && item.content.constructor === ContentFormat) {
|
||||
const key = /** @type {ContentFormat} */ (item.content).key
|
||||
if (attrs.has(key)) {
|
||||
item.delete(transaction)
|
||||
} else {
|
||||
attrs.add(key)
|
||||
}
|
||||
}
|
||||
item = item.left
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is experimental and subject to change / be removed.
|
||||
*
|
||||
* Ideally, we don't need this function at all. Formatting attributes should be cleaned up
|
||||
* automatically after each change. This function iterates twice over the complete YText type
|
||||
* and removes unnecessary formatting attributes. This is also helpful for testing.
|
||||
*
|
||||
* This function won't be exported anymore as soon as there is confidence that the YText type works as intended.
|
||||
*
|
||||
* @param {YText} type
|
||||
* @return {number} How many formatting attributes have been cleaned up.
|
||||
*/
|
||||
export const cleanupYTextFormatting = type => {
|
||||
let res = 0
|
||||
transact(/** @type {Doc} */ (type.doc), transaction => {
|
||||
let start = /** @type {Item} */ (type._start)
|
||||
let end = type._start
|
||||
let startAttributes = map.create()
|
||||
const currentAttributes = map.copy(startAttributes)
|
||||
while (end) {
|
||||
if (end.deleted === false) {
|
||||
switch (end.content.constructor) {
|
||||
case ContentFormat:
|
||||
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (end.content))
|
||||
break
|
||||
case ContentEmbed:
|
||||
case ContentString:
|
||||
res += cleanupFormattingGap(transaction, start, end, startAttributes, currentAttributes)
|
||||
startAttributes = map.copy(currentAttributes)
|
||||
start = end
|
||||
break
|
||||
}
|
||||
}
|
||||
end = end.right
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {ItemListPosition} currPos
|
||||
* @param {Map<string,any>} currentAttributes
|
||||
* @param {number} length
|
||||
* @return {ItemListPosition}
|
||||
@@ -313,7 +441,10 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const deleteText = (transaction, left, right, currentAttributes, length) => {
|
||||
const deleteText = (transaction, currPos, currentAttributes, length) => {
|
||||
const startAttrs = map.copy(currentAttributes)
|
||||
const start = currPos.right
|
||||
let { left, right } = currPos
|
||||
while (length > 0 && right !== null) {
|
||||
if (right.deleted === false) {
|
||||
switch (right.content.constructor) {
|
||||
@@ -323,7 +454,7 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
|
||||
case ContentEmbed:
|
||||
case ContentString:
|
||||
if (length < right.length) {
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(right.id.client, right.id.clock + length))
|
||||
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
|
||||
}
|
||||
length -= right.length
|
||||
right.delete(transaction)
|
||||
@@ -333,7 +464,12 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
|
||||
left = right
|
||||
right = right.right
|
||||
}
|
||||
return { left, right }
|
||||
if (start) {
|
||||
cleanupFormattingGap(transaction, start, right, startAttrs, map.copy(currentAttributes))
|
||||
}
|
||||
currPos.left = left
|
||||
currPos.right = right
|
||||
return currPos
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,11 +518,11 @@ export class YTextEvent extends YEvent {
|
||||
constructor (ytext, transaction) {
|
||||
super(ytext, transaction)
|
||||
/**
|
||||
* @private
|
||||
* @type {Array<DeltaItem>|null}
|
||||
*/
|
||||
this._delta = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the changes in the delta format.
|
||||
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
|
||||
@@ -411,7 +547,10 @@ export class YTextEvent extends YEvent {
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
let attributes = {} // counts added or removed new attributes for retain
|
||||
const attributes = {} // counts added or removed new attributes for retain
|
||||
/**
|
||||
* @type {string|object}
|
||||
*/
|
||||
let insert = ''
|
||||
let retain = 0
|
||||
let deleteLen = 0
|
||||
@@ -430,7 +569,7 @@ export class YTextEvent extends YEvent {
|
||||
op = { insert }
|
||||
if (currentAttributes.size > 0) {
|
||||
op.attributes = {}
|
||||
for (let [key, value] of currentAttributes) {
|
||||
for (const [key, value] of currentAttributes) {
|
||||
if (value !== null) {
|
||||
op.attributes[key] = value
|
||||
}
|
||||
@@ -442,7 +581,7 @@ export class YTextEvent extends YEvent {
|
||||
op = { retain }
|
||||
if (Object.keys(attributes).length > 0) {
|
||||
op.attributes = {}
|
||||
for (let key in attributes) {
|
||||
for (const key in attributes) {
|
||||
op.attributes[key] = attributes[key]
|
||||
}
|
||||
}
|
||||
@@ -500,16 +639,16 @@ export class YTextEvent extends YEvent {
|
||||
retain += item.length
|
||||
}
|
||||
break
|
||||
case ContentFormat:
|
||||
case ContentFormat: {
|
||||
const { key, value } = /** @type {ContentFormat} */ (item.content)
|
||||
if (this.adds(item)) {
|
||||
if (!this.deletes(item)) {
|
||||
const curVal = currentAttributes.get(key) || null
|
||||
if (curVal !== value) {
|
||||
if (!equalAttrs(curVal, value)) {
|
||||
if (action === 'retain') {
|
||||
addOp()
|
||||
}
|
||||
if (value === (oldAttributes.get(key) || null)) {
|
||||
if (equalAttrs(value, (oldAttributes.get(key) || null))) {
|
||||
delete attributes[key]
|
||||
} else {
|
||||
attributes[key] = value
|
||||
@@ -521,7 +660,7 @@ export class YTextEvent extends YEvent {
|
||||
} else if (this.deletes(item)) {
|
||||
oldAttributes.set(key, value)
|
||||
const curVal = currentAttributes.get(key) || null
|
||||
if (curVal !== value) {
|
||||
if (!equalAttrs(curVal, value)) {
|
||||
if (action === 'retain') {
|
||||
addOp()
|
||||
}
|
||||
@@ -531,7 +670,7 @@ export class YTextEvent extends YEvent {
|
||||
oldAttributes.set(key, value)
|
||||
const attr = attributes[key]
|
||||
if (attr !== undefined) {
|
||||
if (attr !== value) {
|
||||
if (!equalAttrs(attr, value)) {
|
||||
if (action === 'retain') {
|
||||
addOp()
|
||||
}
|
||||
@@ -552,12 +691,13 @@ export class YTextEvent extends YEvent {
|
||||
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (item.content))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
item = item.right
|
||||
}
|
||||
addOp()
|
||||
while (delta.length > 0) {
|
||||
let lastOp = delta[delta.length - 1]
|
||||
const lastOp = delta[delta.length - 1]
|
||||
if (lastOp.retain !== undefined && lastOp.attributes === undefined) {
|
||||
// retain delta's if they don't assign attributes
|
||||
delta.pop()
|
||||
@@ -589,11 +729,15 @@ export class YText extends AbstractType {
|
||||
/**
|
||||
* Array of pending operations on this type
|
||||
* @type {Array<function():void>?}
|
||||
* @private
|
||||
*/
|
||||
this._pending = string !== undefined ? [() => this.insert(0, string)] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of characters of this text type.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
get length () {
|
||||
return this._length
|
||||
}
|
||||
@@ -601,8 +745,6 @@ export class YText extends AbstractType {
|
||||
/**
|
||||
* @param {Doc} y
|
||||
* @param {Item} item
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
@@ -614,16 +756,59 @@ export class YText extends AbstractType {
|
||||
this._pending = null
|
||||
}
|
||||
|
||||
_copy () {
|
||||
return new YText()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates YTextEvent and calls observers.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
callTypeObservers(this, transaction, new YTextEvent(this, transaction))
|
||||
const event = new YTextEvent(this, transaction)
|
||||
const doc = transaction.doc
|
||||
// If a remote change happened, we try to cleanup potential formatting duplicates.
|
||||
if (!transaction.local) {
|
||||
// check if another formatting item was inserted
|
||||
let foundFormattingItem = false
|
||||
for (const [client, afterClock] of transaction.afterState) {
|
||||
const clock = transaction.beforeState.get(client) || 0
|
||||
if (afterClock === clock) {
|
||||
continue
|
||||
}
|
||||
iterateStructs(transaction, /** @type {Array<Item|GC>} */ (doc.store.clients.get(client)), clock, afterClock, item => {
|
||||
// @ts-ignore
|
||||
if (!item.deleted && item.content.constructor === ContentFormat) {
|
||||
foundFormattingItem = true
|
||||
}
|
||||
})
|
||||
if (foundFormattingItem) {
|
||||
break
|
||||
}
|
||||
}
|
||||
transact(doc, t => {
|
||||
if (foundFormattingItem) {
|
||||
// If a formatting item was inserted, we simply clean the whole type.
|
||||
// We need to compute currentAttributes for the current position anyway.
|
||||
cleanupYTextFormatting(this)
|
||||
} else {
|
||||
// If no formatting attribute was inserted, we can make due with contextless
|
||||
// formatting cleanups.
|
||||
// Contextless: it is not necessary to compute currentAttributes for the affected position.
|
||||
iterateDeletedStructs(t, transaction.deleteSet, item => {
|
||||
if (item instanceof GC) {
|
||||
return
|
||||
}
|
||||
if (item.parent === this) {
|
||||
cleanupContextlessFormattingGap(t, item)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
callTypeObservers(this, transaction, event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -646,29 +831,50 @@ export class YText extends AbstractType {
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unformatted string representation of this YText type.
|
||||
*
|
||||
* @return {string}
|
||||
* @public
|
||||
*/
|
||||
toJSON () {
|
||||
return this.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a {@link Delta} on this shared YText type.
|
||||
*
|
||||
* @param {any} delta The changes to apply on this element.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.sanitize] Sanitize input delta. Removes ending newlines if set to true.
|
||||
*
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
applyDelta (delta) {
|
||||
applyDelta (delta, { sanitize = true } = {}) {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
/**
|
||||
* @type {ItemListPosition}
|
||||
*/
|
||||
let pos = new ItemListPosition(null, this._start)
|
||||
const currPos = new ItemListPosition(null, this._start)
|
||||
const currentAttributes = new Map()
|
||||
for (let i = 0; i < delta.length; i++) {
|
||||
const op = delta[i]
|
||||
if (op.insert !== undefined) {
|
||||
pos = insertText(transaction, this, pos.left, pos.right, currentAttributes, op.insert, op.attributes || {})
|
||||
// Quill assumes that the content starts with an empty paragraph.
|
||||
// Yjs/Y.Text assumes that it starts empty. We always hide that
|
||||
// there is a newline at the end of the content.
|
||||
// If we omit this step, clients will see a different number of
|
||||
// paragraphs, but nothing bad will happen.
|
||||
const ins = (!sanitize && typeof op.insert === 'string' && i === delta.length - 1 && currPos.right === null && op.insert.slice(-1) === '\n') ? op.insert.slice(0, -1) : op.insert
|
||||
if (typeof ins !== 'string' || ins.length > 0) {
|
||||
insertText(transaction, this, currPos, currentAttributes, ins, op.attributes || {})
|
||||
}
|
||||
} else if (op.retain !== undefined) {
|
||||
pos = formatText(transaction, this, pos.left, pos.right, currentAttributes, op.retain, op.attributes || {})
|
||||
formatText(transaction, this, currPos, currentAttributes, op.retain, op.attributes || {})
|
||||
} else if (op.delete !== undefined) {
|
||||
pos = deleteText(transaction, pos.left, pos.right, currentAttributes, op.delete)
|
||||
deleteText(transaction, currPos, currentAttributes, op.delete)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -682,16 +888,18 @@ export class YText extends AbstractType {
|
||||
*
|
||||
* @param {Snapshot} [snapshot]
|
||||
* @param {Snapshot} [prevSnapshot]
|
||||
* @param {function('removed' | 'added', ID):any} [computeYChange]
|
||||
* @return {any} The Delta representation of this type.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
toDelta (snapshot, prevSnapshot) {
|
||||
toDelta (snapshot, prevSnapshot, computeYChange) {
|
||||
/**
|
||||
* @type{Array<any>}
|
||||
*/
|
||||
const ops = []
|
||||
const currentAttributes = new Map()
|
||||
const doc = /** @type {Doc} */ (this.doc)
|
||||
let str = ''
|
||||
let n = this._start
|
||||
function packStr () {
|
||||
@@ -702,7 +910,7 @@ export class YText extends AbstractType {
|
||||
*/
|
||||
const attributes = {}
|
||||
let addAttributes = false
|
||||
for (let [key, value] of currentAttributes) {
|
||||
for (const [key, value] of currentAttributes) {
|
||||
addAttributes = true
|
||||
attributes[key] = value
|
||||
}
|
||||
@@ -717,42 +925,67 @@ export class YText extends AbstractType {
|
||||
str = ''
|
||||
}
|
||||
}
|
||||
while (n !== null) {
|
||||
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
|
||||
switch (n.content.constructor) {
|
||||
case ContentString:
|
||||
const cur = currentAttributes.get('ychange')
|
||||
if (snapshot !== undefined && !isVisible(n, snapshot)) {
|
||||
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') {
|
||||
packStr()
|
||||
currentAttributes.set('ychange', { user: n.id.client, state: 'removed' })
|
||||
}
|
||||
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
|
||||
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') {
|
||||
packStr()
|
||||
currentAttributes.set('ychange', { user: n.id.client, state: 'added' })
|
||||
}
|
||||
} else if (cur !== undefined) {
|
||||
packStr()
|
||||
currentAttributes.delete('ychange')
|
||||
}
|
||||
str += /** @type {ContentString} */ (n.content).str
|
||||
break
|
||||
case ContentEmbed:
|
||||
packStr()
|
||||
ops.push({
|
||||
insert: /** @type {ContentEmbed} */ (n.content).embed
|
||||
})
|
||||
break
|
||||
case ContentFormat:
|
||||
packStr()
|
||||
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
|
||||
break
|
||||
}
|
||||
// snapshots are merged again after the transaction, so we need to keep the
|
||||
// transalive until we are done
|
||||
transact(doc, transaction => {
|
||||
if (snapshot) {
|
||||
splitSnapshotAffectedStructs(transaction, snapshot)
|
||||
}
|
||||
n = n.right
|
||||
}
|
||||
packStr()
|
||||
if (prevSnapshot) {
|
||||
splitSnapshotAffectedStructs(transaction, prevSnapshot)
|
||||
}
|
||||
while (n !== null) {
|
||||
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
|
||||
switch (n.content.constructor) {
|
||||
case ContentString: {
|
||||
const cur = currentAttributes.get('ychange')
|
||||
if (snapshot !== undefined && !isVisible(n, snapshot)) {
|
||||
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') {
|
||||
packStr()
|
||||
currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
|
||||
}
|
||||
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
|
||||
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') {
|
||||
packStr()
|
||||
currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
|
||||
}
|
||||
} else if (cur !== undefined) {
|
||||
packStr()
|
||||
currentAttributes.delete('ychange')
|
||||
}
|
||||
str += /** @type {ContentString} */ (n.content).str
|
||||
break
|
||||
}
|
||||
case ContentEmbed: {
|
||||
packStr()
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
const op = {
|
||||
insert: /** @type {ContentEmbed} */ (n.content).embed
|
||||
}
|
||||
if (currentAttributes.size > 0) {
|
||||
const attrs = /** @type {Object<string,any>} */ ({})
|
||||
op.attributes = attrs
|
||||
for (const [key, value] of currentAttributes) {
|
||||
attrs[key] = value
|
||||
}
|
||||
}
|
||||
ops.push(op)
|
||||
break
|
||||
}
|
||||
case ContentFormat:
|
||||
if (isVisible(n, snapshot)) {
|
||||
packStr()
|
||||
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
n = n.right
|
||||
}
|
||||
packStr()
|
||||
}, splitSnapshotAffectedStructs)
|
||||
return ops
|
||||
}
|
||||
|
||||
@@ -761,20 +994,25 @@ export class YText extends AbstractType {
|
||||
*
|
||||
* @param {number} index The index at which to start inserting.
|
||||
* @param {String} text The text to insert at the specified position.
|
||||
* @param {TextAttributes} attributes Optionally define some formatting
|
||||
* @param {TextAttributes} [attributes] Optionally define some formatting
|
||||
* information to apply on the inserted
|
||||
* Text.
|
||||
* @public
|
||||
*/
|
||||
insert (index, text, attributes = {}) {
|
||||
insert (index, text, attributes) {
|
||||
if (text.length <= 0) {
|
||||
return
|
||||
}
|
||||
const y = this.doc
|
||||
if (y !== null) {
|
||||
transact(y, transaction => {
|
||||
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
|
||||
insertText(transaction, this, left, right, currentAttributes, text, attributes)
|
||||
const { left, right, currentAttributes } = findPosition(transaction, this, index)
|
||||
if (!attributes) {
|
||||
attributes = {}
|
||||
// @ts-ignore
|
||||
currentAttributes.forEach((v, k) => { attributes[k] = v })
|
||||
}
|
||||
insertText(transaction, this, new ItemListPosition(left, right), currentAttributes, text, attributes)
|
||||
})
|
||||
} else {
|
||||
/** @type {Array<function>} */ (this._pending).push(() => this.insert(index, text, attributes))
|
||||
@@ -798,8 +1036,8 @@ export class YText extends AbstractType {
|
||||
const y = this.doc
|
||||
if (y !== null) {
|
||||
transact(y, transaction => {
|
||||
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
|
||||
insertText(transaction, this, left, right, currentAttributes, embed, attributes)
|
||||
const { left, right, currentAttributes } = findPosition(transaction, this, index)
|
||||
insertText(transaction, this, new ItemListPosition(left, right), currentAttributes, embed, attributes)
|
||||
})
|
||||
} else {
|
||||
/** @type {Array<function>} */ (this._pending).push(() => this.insertEmbed(index, embed, attributes))
|
||||
@@ -821,8 +1059,8 @@ export class YText extends AbstractType {
|
||||
const y = this.doc
|
||||
if (y !== null) {
|
||||
transact(y, transaction => {
|
||||
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
|
||||
deleteText(transaction, left, right, currentAttributes, length)
|
||||
const { left, right, currentAttributes } = findPosition(transaction, this, index)
|
||||
deleteText(transaction, new ItemListPosition(left, right), currentAttributes, length)
|
||||
})
|
||||
} else {
|
||||
/** @type {Array<function>} */ (this._pending).push(() => this.delete(index, length))
|
||||
@@ -840,14 +1078,17 @@ export class YText extends AbstractType {
|
||||
* @public
|
||||
*/
|
||||
format (index, length, attributes) {
|
||||
if (length === 0) {
|
||||
return
|
||||
}
|
||||
const y = this.doc
|
||||
if (y !== null) {
|
||||
transact(y, transaction => {
|
||||
let { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
|
||||
const { left, right, currentAttributes } = findPosition(transaction, this, index)
|
||||
if (right === null) {
|
||||
return
|
||||
}
|
||||
formatText(transaction, this, left, right, currentAttributes, length, attributes)
|
||||
formatText(transaction, this, new ItemListPosition(left, right), currentAttributes, length, attributes)
|
||||
})
|
||||
} else {
|
||||
/** @type {Array<function>} */ (this._pending).push(() => this.format(index, length, attributes))
|
||||
@@ -856,8 +1097,6 @@ export class YText extends AbstractType {
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoding.writeVarUint(encoder, YTextRefID)
|
||||
|
||||
@@ -24,10 +24,9 @@ import * as decoding from 'lib0/decoding.js'
|
||||
export class YXmlElement extends YXmlFragment {
|
||||
constructor (nodeName = 'UNDEFINED') {
|
||||
super()
|
||||
this.nodeName = nodeName.toUpperCase()
|
||||
this.nodeName = nodeName
|
||||
/**
|
||||
* @type {Map<string, any>|null}
|
||||
* @private
|
||||
*/
|
||||
this._prelimAttrs = new Map()
|
||||
}
|
||||
@@ -41,23 +40,19 @@ export class YXmlElement extends YXmlFragment {
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
this.insert(0, /** @type {Array} */ (this._prelimContent))
|
||||
this._prelimContent = null
|
||||
;(/** @type {Map<string, any>} */ (this._prelimAttrs)).forEach((value, key) => {
|
||||
this.setAttribute(key, value)
|
||||
})
|
||||
this._prelimContent = null
|
||||
this._prelimAttrs = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Item with the same effect as this Item (without position effect)
|
||||
*
|
||||
* @return {YXmlElement}
|
||||
* @private
|
||||
*/
|
||||
_copy () {
|
||||
return new YXmlElement(this.nodeName)
|
||||
@@ -76,7 +71,7 @@ export class YXmlElement extends YXmlFragment {
|
||||
const attrs = this.getAttributes()
|
||||
const stringBuilder = []
|
||||
const keys = []
|
||||
for (let key in attrs) {
|
||||
for (const key in attrs) {
|
||||
keys.push(key)
|
||||
}
|
||||
keys.sort()
|
||||
@@ -142,7 +137,7 @@ export class YXmlElement extends YXmlFragment {
|
||||
* Returns all attribute name/value pairs in a JSON Object.
|
||||
*
|
||||
* @param {Snapshot} [snapshot]
|
||||
* @return {Object} A JSON Object that describes the attributes.
|
||||
* @return {Object<string, any>} A JSON Object that describes the attributes.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
@@ -167,8 +162,8 @@ export class YXmlElement extends YXmlFragment {
|
||||
*/
|
||||
toDOM (_document = document, hooks = {}, binding) {
|
||||
const dom = _document.createElement(this.nodeName)
|
||||
let attrs = this.getAttributes()
|
||||
for (let key in attrs) {
|
||||
const attrs = this.getAttributes()
|
||||
for (const key in attrs) {
|
||||
dom.setAttribute(key, attrs[key])
|
||||
}
|
||||
typeListForEach(this, yxml => {
|
||||
@@ -186,7 +181,6 @@ export class YXmlElement extends YXmlFragment {
|
||||
*
|
||||
* This is called when this Item is sent to a remote peer.
|
||||
*
|
||||
* @private
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
*/
|
||||
_write (encoder) {
|
||||
@@ -199,7 +193,6 @@ export class YXmlElement extends YXmlFragment {
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {YXmlElement}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder))
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
YXmlFragmentRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
|
||||
Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -48,7 +48,7 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
* Can be created with {@link YXmlFragment#createTreeWalker}
|
||||
*
|
||||
* @public
|
||||
* @implements {IterableIterator}
|
||||
* @implements {Iterable<YXmlElement|YXmlText|YXmlElement|YXmlHook>}
|
||||
*/
|
||||
export class YXmlTreeWalker {
|
||||
/**
|
||||
@@ -68,6 +68,7 @@ export class YXmlTreeWalker {
|
||||
[Symbol.iterator] () {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next node.
|
||||
*
|
||||
@@ -80,10 +81,10 @@ export class YXmlTreeWalker {
|
||||
* @type {Item|null}
|
||||
*/
|
||||
let n = this._currentNode
|
||||
let type = /** @type {ContentType} */ (n.content).type
|
||||
let type = /** @type {any} */ (n.content).type
|
||||
if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { // if first call, we check if we can use the first item
|
||||
do {
|
||||
type = /** @type {ContentType} */ (n.content).type
|
||||
type = /** @type {any} */ (n.content).type
|
||||
if (!n.deleted && (type.constructor === YXmlElement || type.constructor === YXmlFragment) && type._start !== null) {
|
||||
// walk down in the tree
|
||||
n = type._start
|
||||
@@ -96,7 +97,7 @@ export class YXmlTreeWalker {
|
||||
} else if (n.parent === this._root) {
|
||||
n = null
|
||||
} else {
|
||||
n = n.parent._item
|
||||
n = /** @type {AbstractType<any>} */ (n.parent)._item
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,10 +127,34 @@ export class YXmlFragment extends AbstractType {
|
||||
super()
|
||||
/**
|
||||
* @type {Array<any>|null}
|
||||
* @private
|
||||
*/
|
||||
this._prelimContent = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrate this type into the Yjs instance.
|
||||
*
|
||||
* * Save this struct in the os
|
||||
* * This type is sent to other client
|
||||
* * Observer functions are fired
|
||||
*
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {Item} item
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
|
||||
this._prelimContent = null
|
||||
}
|
||||
|
||||
_copy () {
|
||||
return new YXmlFragment()
|
||||
}
|
||||
|
||||
get length () {
|
||||
return this._prelimContent === null ? this._length : this._prelimContent.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subtree of childNodes.
|
||||
*
|
||||
@@ -169,7 +194,7 @@ export class YXmlFragment extends AbstractType {
|
||||
querySelector (query) {
|
||||
query = query.toUpperCase()
|
||||
// @ts-ignore
|
||||
const iterator = new YXmlTreeWalker(this, element => element.nodeName === query)
|
||||
const iterator = new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query)
|
||||
const next = iterator.next()
|
||||
if (next.done) {
|
||||
return null
|
||||
@@ -192,12 +217,11 @@ export class YXmlFragment extends AbstractType {
|
||||
querySelectorAll (query) {
|
||||
query = query.toUpperCase()
|
||||
// @ts-ignore
|
||||
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === query))
|
||||
return Array.from(new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates YXmlEvent and calls observers.
|
||||
* @private
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
|
||||
@@ -215,6 +239,9 @@ export class YXmlFragment extends AbstractType {
|
||||
return typeListMap(this, xml => xml.toString()).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
toJSON () {
|
||||
return this.toString()
|
||||
}
|
||||
@@ -282,6 +309,7 @@ export class YXmlFragment extends AbstractType {
|
||||
this._prelimContent.splice(index, length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms this YArray to a JavaScript Array.
|
||||
*
|
||||
@@ -290,13 +318,13 @@ export class YXmlFragment extends AbstractType {
|
||||
toArray () {
|
||||
return typeListToArray(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the properties of this type to binary and write it to an
|
||||
* BinaryEncoder.
|
||||
*
|
||||
* This is called when this Item is sent to a remote peer.
|
||||
*
|
||||
* @private
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
*/
|
||||
_write (encoder) {
|
||||
|
||||
@@ -25,8 +25,6 @@ export class YXmlHook extends YMap {
|
||||
|
||||
/**
|
||||
* Creates an Item with the same effect as this Item (without position effect)
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_copy () {
|
||||
return new YXmlHook(this.hookName)
|
||||
@@ -69,8 +67,6 @@ export class YXmlHook extends YMap {
|
||||
* This is called when this Item is sent to a remote peer.
|
||||
*
|
||||
* @param {encoding.Encoder} encoder The encoder to write data to.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_write (encoder) {
|
||||
super._write(encoder)
|
||||
|
||||
@@ -9,6 +9,10 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
* simple formatting information like bold and italic.
|
||||
*/
|
||||
export class YXmlText extends YText {
|
||||
_copy () {
|
||||
return new YXmlText()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Dom Element that mirrors this YXmlText.
|
||||
*
|
||||
@@ -36,9 +40,9 @@ export class YXmlText extends YText {
|
||||
// @ts-ignore
|
||||
return this.toDelta().map(delta => {
|
||||
const nestedNodes = []
|
||||
for (let nodeName in delta.attributes) {
|
||||
for (const nodeName in delta.attributes) {
|
||||
const attrs = []
|
||||
for (let key in delta.attributes[nodeName]) {
|
||||
for (const key in delta.attributes[nodeName]) {
|
||||
attrs.push({ key, value: delta.attributes[nodeName][key] })
|
||||
}
|
||||
// sort attributes to get a unique order
|
||||
@@ -53,7 +57,7 @@ export class YXmlText extends YText {
|
||||
const node = nestedNodes[i]
|
||||
str += `<${node.nodeName}`
|
||||
for (let j = 0; j < node.attrs.length; j++) {
|
||||
const attr = node.attrs[i]
|
||||
const attr = node.attrs[j]
|
||||
str += ` ${attr.key}="${attr.value}"`
|
||||
}
|
||||
str += '>'
|
||||
@@ -66,14 +70,15 @@ export class YXmlText extends YText {
|
||||
}).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
toJSON () {
|
||||
return this.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_write (encoder) {
|
||||
encoding.writeVarUint(encoder, YXmlTextRefID)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
|
||||
import {
|
||||
findIndexSS,
|
||||
createID,
|
||||
getState,
|
||||
splitItem,
|
||||
Item, AbstractStruct, StructStore, Transaction, ID // eslint-disable-line
|
||||
createID,
|
||||
iterateStructs,
|
||||
Item, AbstractStruct, GC, StructStore, Transaction, ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as array from 'lib0/array.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
import * as map from 'lib0/map.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -40,36 +42,26 @@ export class DeleteSet {
|
||||
constructor () {
|
||||
/**
|
||||
* @type {Map<number,Array<DeleteItem>>}
|
||||
* @private
|
||||
*/
|
||||
this.clients = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over all structs that were deleted.
|
||||
*
|
||||
* This function expects that the deletes structs are not merged. Hence, you can
|
||||
* probably only use it in type observes and `afterTransaction` events. But not
|
||||
* in `afterTransactionCleanup`.
|
||||
* Iterate over all structs that the DeleteSet gc's.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
* @param {function(AbstractStruct):void} f
|
||||
* @param {function(GC|Item):void} f
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const iterateDeletedStructs = (ds, store, f) =>
|
||||
export const iterateDeletedStructs = (transaction, ds, f) =>
|
||||
ds.clients.forEach((deletes, clientid) => {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(clientid))
|
||||
const structs = /** @type {Array<GC|Item>} */ (transaction.doc.store.clients.get(clientid))
|
||||
for (let i = 0; i < deletes.length; i++) {
|
||||
const del = deletes[i]
|
||||
let index = findIndexSS(structs, del.clock)
|
||||
let struct
|
||||
do {
|
||||
struct = structs[index++]
|
||||
f(struct)
|
||||
} while (index < structs.length && structs[index].id.clock < del.clock + del.len)
|
||||
iterateStructs(transaction, structs, del.clock, del.len, f)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -143,6 +135,32 @@ export const sortAndMergeDeleteSet = ds => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<DeleteSet>} dss
|
||||
* @return {DeleteSet} A fresh DeleteSet
|
||||
*/
|
||||
export const mergeDeleteSets = dss => {
|
||||
const merged = new DeleteSet()
|
||||
for (let dssI = 0; dssI < dss.length; dssI++) {
|
||||
dss[dssI].clients.forEach((delsLeft, client) => {
|
||||
if (!merged.clients.has(client)) {
|
||||
// Write all missing keys from current ds and all following.
|
||||
// If merged already contains `client` current ds has already been added.
|
||||
/**
|
||||
* @type {Array<DeleteItem>}
|
||||
*/
|
||||
const dels = delsLeft.slice()
|
||||
for (let i = dssI + 1; i < dss.length; i++) {
|
||||
array.appendTo(dels, dss[i].clients.get(client) || [])
|
||||
}
|
||||
merged.clients.set(client, dels)
|
||||
}
|
||||
})
|
||||
}
|
||||
sortAndMergeDeleteSet(merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {ID} id
|
||||
@@ -155,6 +173,8 @@ export const addToDeleteSet = (ds, id, length) => {
|
||||
map.setIfUndefined(ds.clients, id.client, () => []).push(new DeleteItem(id.clock, length))
|
||||
}
|
||||
|
||||
export const createDeleteSet = () => new DeleteSet()
|
||||
|
||||
/**
|
||||
* @param {StructStore} ss
|
||||
* @return {DeleteSet} Merged and sorted DeleteSet
|
||||
@@ -163,7 +183,7 @@ export const addToDeleteSet = (ds, id, length) => {
|
||||
* @function
|
||||
*/
|
||||
export const createDeleteSetFromStructStore = ss => {
|
||||
const ds = new DeleteSet()
|
||||
const ds = createDeleteSet()
|
||||
ss.clients.forEach((structs, client) => {
|
||||
/**
|
||||
* @type {Array<DeleteItem>}
|
||||
@@ -210,6 +230,26 @@ export const writeDeleteSet = (encoder, ds) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {DeleteSet}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readDeleteSet = decoder => {
|
||||
const ds = new DeleteSet()
|
||||
const numClients = decoding.readVarUint(decoder)
|
||||
for (let i = 0; i < numClients; i++) {
|
||||
const client = decoding.readVarUint(decoder)
|
||||
const numberOfDeletes = decoding.readVarUint(decoder)
|
||||
for (let i = 0; i < numberOfDeletes; i++) {
|
||||
addToDeleteSet(ds, createID(client, decoding.readVarUint(decoder)), decoding.readVarUint(decoder))
|
||||
}
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {Transaction} transaction
|
||||
@@ -218,7 +258,7 @@ export const writeDeleteSet = (encoder, ds) => {
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readDeleteSet = (decoder, transaction, store) => {
|
||||
export const readAndApplyDeleteSet = (decoder, transaction, store) => {
|
||||
const unappliedDS = new DeleteSet()
|
||||
const numClients = decoding.readVarUint(decoder)
|
||||
for (let i = 0; i < numClients; i++) {
|
||||
@@ -265,6 +305,7 @@ export const readDeleteSet = (decoder, transaction, store) => {
|
||||
}
|
||||
}
|
||||
if (unappliedDS.clients.size > 0) {
|
||||
// TODO: no need for encoding+decoding ds anymore
|
||||
const unappliedDSEncoder = encoding.createEncoder()
|
||||
writeDeleteSet(unappliedDSEncoder, unappliedDS)
|
||||
store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toUint8Array(unappliedDSEncoder)))
|
||||
|
||||
@@ -17,17 +17,23 @@ import { Observable } from 'lib0/observable.js'
|
||||
import * as random from 'lib0/random.js'
|
||||
import * as map from 'lib0/map.js'
|
||||
|
||||
export const generateNewClientId = random.uint32
|
||||
|
||||
/**
|
||||
* A Yjs instance handles the state of shared data.
|
||||
* @extends Observable<string>
|
||||
*/
|
||||
export class Doc extends Observable {
|
||||
/**
|
||||
* @param {Object|undefined} conf configuration
|
||||
* @param {Object} conf configuration
|
||||
* @param {boolean} [conf.gc] Disable garbage collection (default: gc=true)
|
||||
* @param {function(Item):boolean} [conf.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
|
||||
*/
|
||||
constructor (conf = {}) {
|
||||
constructor ({ gc = true, gcFilter = () => true } = {}) {
|
||||
super()
|
||||
this.clientID = random.uint32()
|
||||
this.gc = gc
|
||||
this.gcFilter = gcFilter
|
||||
this.clientID = generateNewClientId()
|
||||
/**
|
||||
* @type {Map<string, AbstractType<YEvent>>}
|
||||
*/
|
||||
@@ -35,15 +41,14 @@ export class Doc extends Observable {
|
||||
this.store = new StructStore()
|
||||
/**
|
||||
* @type {Transaction | null}
|
||||
* @private
|
||||
*/
|
||||
this._transaction = null
|
||||
/**
|
||||
* @type {Array<Transaction>}
|
||||
* @private
|
||||
*/
|
||||
this._transactionCleanups = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes that happen inside of a transaction are bundled. This means that
|
||||
* the observer fires _after_ the transaction is finished and that all changes
|
||||
@@ -58,6 +63,7 @@ export class Doc extends Observable {
|
||||
transact (f, origin = null) {
|
||||
transact(this, f, origin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a shared data type.
|
||||
*
|
||||
@@ -99,6 +105,7 @@ export class Doc extends Observable {
|
||||
t._map = type._map
|
||||
type._map.forEach(/** @param {Item?} n */ n => {
|
||||
for (; n !== null; n = n.left) {
|
||||
// @ts-ignore
|
||||
n.parent = t
|
||||
}
|
||||
})
|
||||
@@ -116,6 +123,7 @@ export class Doc extends Observable {
|
||||
}
|
||||
return type
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {string} name
|
||||
@@ -127,6 +135,7 @@ export class Doc extends Observable {
|
||||
// @ts-ignore
|
||||
return this.get(name, YArray)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @return {YText}
|
||||
@@ -137,6 +146,7 @@ export class Doc extends Observable {
|
||||
// @ts-ignore
|
||||
return this.get(name, YText)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @return {YMap<any>}
|
||||
@@ -147,6 +157,7 @@ export class Doc extends Observable {
|
||||
// @ts-ignore
|
||||
return this.get(name, YMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @return {YXmlFragment}
|
||||
@@ -157,15 +168,15 @@ export class Doc extends Observable {
|
||||
// @ts-ignore
|
||||
return this.get(name, YXmlFragment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `destroy` event and unregister all event handlers.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
destroy () {
|
||||
this.emit('destroyed', [true])
|
||||
super.destroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventName
|
||||
* @param {function} f
|
||||
@@ -173,6 +184,7 @@ export class Doc extends Observable {
|
||||
on (eventName, f) {
|
||||
super.on(eventName, f)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventName
|
||||
* @param {function} f
|
||||
|
||||
@@ -81,7 +81,7 @@ export const readID = decoder =>
|
||||
*/
|
||||
export const findRootTypeKey = type => {
|
||||
// @ts-ignore _y must be defined, otherwise unexpected case
|
||||
for (let [key, value] of type.doc.share) {
|
||||
for (const [key, value] of type.doc.share) {
|
||||
if (value === type) {
|
||||
return key
|
||||
}
|
||||
|
||||
142
src/utils/PermanentUserData.js
Normal file
142
src/utils/PermanentUserData.js
Normal file
@@ -0,0 +1,142 @@
|
||||
|
||||
import {
|
||||
YArray,
|
||||
YMap,
|
||||
readDeleteSet,
|
||||
writeDeleteSet,
|
||||
createDeleteSet,
|
||||
ID, DeleteSet, YArrayEvent, Transaction, Doc // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import { mergeDeleteSets, isDeleted } from './DeleteSet.js'
|
||||
|
||||
export class PermanentUserData {
|
||||
/**
|
||||
* @param {Doc} doc
|
||||
* @param {YMap<any>} [storeType]
|
||||
*/
|
||||
constructor (doc, storeType = doc.getMap('users')) {
|
||||
/**
|
||||
* @type {Map<string,DeleteSet>}
|
||||
*/
|
||||
const dss = new Map()
|
||||
this.yusers = storeType
|
||||
this.doc = doc
|
||||
/**
|
||||
* Maps from clientid to userDescription
|
||||
*
|
||||
* @type {Map<number,string>}
|
||||
*/
|
||||
this.clients = new Map()
|
||||
this.dss = dss
|
||||
/**
|
||||
* @param {YMap<any>} user
|
||||
* @param {string} userDescription
|
||||
*/
|
||||
const initUser = (user, userDescription) => {
|
||||
/**
|
||||
* @type {YArray<Uint8Array>}
|
||||
*/
|
||||
const ds = user.get('ds')
|
||||
const ids = user.get('ids')
|
||||
const addClientId = /** @param {number} clientid */ clientid => this.clients.set(clientid, userDescription)
|
||||
ds.observe(/** @param {YArrayEvent<any>} event */ event => {
|
||||
event.changes.added.forEach(item => {
|
||||
item.content.getContent().forEach(encodedDs => {
|
||||
if (encodedDs instanceof Uint8Array) {
|
||||
this.dss.set(userDescription, mergeDeleteSets([this.dss.get(userDescription) || createDeleteSet(), readDeleteSet(decoding.createDecoder(encodedDs))]))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(decoding.createDecoder(encodedDs)))))
|
||||
ids.observe(/** @param {YArrayEvent<any>} event */ event =>
|
||||
event.changes.added.forEach(item => item.content.getContent().forEach(addClientId))
|
||||
)
|
||||
ids.forEach(addClientId)
|
||||
}
|
||||
// observe users
|
||||
storeType.observe(event => {
|
||||
event.keysChanged.forEach(userDescription =>
|
||||
initUser(storeType.get(userDescription), userDescription)
|
||||
)
|
||||
})
|
||||
// add intial data
|
||||
storeType.forEach(initUser)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Doc} doc
|
||||
* @param {number} clientid
|
||||
* @param {string} userDescription
|
||||
* @param {Object} [conf]
|
||||
* @param {function(Transaction, DeleteSet):boolean} [conf.filter]
|
||||
*/
|
||||
setUserMapping (doc, clientid, userDescription, { filter = () => true } = {}) {
|
||||
const users = this.yusers
|
||||
let user = users.get(userDescription)
|
||||
if (!user) {
|
||||
user = new YMap()
|
||||
user.set('ids', new YArray())
|
||||
user.set('ds', new YArray())
|
||||
users.set(userDescription, user)
|
||||
}
|
||||
user.get('ids').push([clientid])
|
||||
users.observe(event => {
|
||||
setTimeout(() => {
|
||||
const userOverwrite = users.get(userDescription)
|
||||
if (userOverwrite !== user) {
|
||||
// user was overwritten, port all data over to the next user object
|
||||
// @todo Experiment with Y.Sets here
|
||||
user = userOverwrite
|
||||
// @todo iterate over old type
|
||||
this.clients.forEach((_userDescription, clientid) => {
|
||||
if (userDescription === _userDescription) {
|
||||
user.get('ids').push([clientid])
|
||||
}
|
||||
})
|
||||
const encoder = encoding.createEncoder()
|
||||
const ds = this.dss.get(userDescription)
|
||||
if (ds) {
|
||||
writeDeleteSet(encoder, ds)
|
||||
user.get('ds').push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
|
||||
setTimeout(() => {
|
||||
const yds = user.get('ds')
|
||||
const ds = transaction.deleteSet
|
||||
if (transaction.local && ds.clients.size > 0 && filter(transaction, ds)) {
|
||||
const encoder = encoding.createEncoder()
|
||||
writeDeleteSet(encoder, ds)
|
||||
yds.push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} clientid
|
||||
* @return {any}
|
||||
*/
|
||||
getUserByClientId (clientid) {
|
||||
return this.clients.get(clientid) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ID} id
|
||||
* @return {string | null}
|
||||
*/
|
||||
getUserByDeletedId (id) {
|
||||
for (const [userDescription, ds] of this.dss) {
|
||||
if (isDeleted(ds, id)) {
|
||||
return userDescription
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
|
||||
import {
|
||||
getItem,
|
||||
createID,
|
||||
writeID,
|
||||
readID,
|
||||
compareIDs,
|
||||
getState,
|
||||
findRootTypeKey,
|
||||
Item,
|
||||
createID,
|
||||
ContentType,
|
||||
followRedone,
|
||||
ID, Doc, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
@@ -63,7 +63,7 @@ export class RelativePosition {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} json
|
||||
* @param {any} json
|
||||
* @return {RelativePosition}
|
||||
*
|
||||
* @function
|
||||
@@ -107,7 +107,7 @@ export const createRelativePosition = (type, item) => {
|
||||
if (type._item === null) {
|
||||
tname = findRootTypeKey(type)
|
||||
} else {
|
||||
typeid = type._item.id
|
||||
typeid = createID(type._item.id.client, type._item.id.clock)
|
||||
}
|
||||
return new RelativePosition(typeid, tname, item)
|
||||
}
|
||||
@@ -222,19 +222,22 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
|
||||
if (getState(store, rightID.client) <= rightID.clock) {
|
||||
return null
|
||||
}
|
||||
const right = getItem(store, rightID)
|
||||
const res = followRedone(store, rightID)
|
||||
const right = res.item
|
||||
if (!(right instanceof Item)) {
|
||||
return null
|
||||
}
|
||||
index = right.deleted || !right.countable ? 0 : rightID.clock - right.id.clock
|
||||
let n = right.left
|
||||
while (n !== null) {
|
||||
if (!n.deleted && n.countable) {
|
||||
index += n.length
|
||||
type = /** @type {AbstractType<any>} */ (right.parent)
|
||||
if (type._item === null || !type._item.deleted) {
|
||||
index = right.deleted || !right.countable ? 0 : res.diff
|
||||
let n = right.left
|
||||
while (n !== null) {
|
||||
if (!n.deleted && n.countable) {
|
||||
index += n.length
|
||||
}
|
||||
n = n.left
|
||||
}
|
||||
n = n.left
|
||||
}
|
||||
type = right.parent
|
||||
} else {
|
||||
if (tname !== null) {
|
||||
type = doc.get(tname)
|
||||
@@ -243,9 +246,9 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
|
||||
// type does not exist yet
|
||||
return null
|
||||
}
|
||||
const struct = getItem(store, typeID)
|
||||
if (struct instanceof Item && struct.content instanceof ContentType) {
|
||||
type = struct.content.type
|
||||
const { item } = followRedone(store, typeID)
|
||||
if (item instanceof Item && item.content instanceof ContentType) {
|
||||
type = item.content.type
|
||||
} else {
|
||||
// struct is garbage collected
|
||||
return null
|
||||
@@ -255,9 +258,6 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
|
||||
}
|
||||
index = type._length
|
||||
}
|
||||
if (type._item !== null && type._item.deleted) {
|
||||
return null
|
||||
}
|
||||
return createAbsolutePosition(type, index)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,112 @@
|
||||
|
||||
import {
|
||||
isDeleted,
|
||||
DeleteSet, Item // eslint-disable-line
|
||||
createDeleteSetFromStructStore,
|
||||
getStateVector,
|
||||
getItemCleanStart,
|
||||
iterateDeletedStructs,
|
||||
writeDeleteSet,
|
||||
writeStateVector,
|
||||
readDeleteSet,
|
||||
readStateVector,
|
||||
createDeleteSet,
|
||||
createID,
|
||||
getState,
|
||||
Transaction, Doc, DeleteSet, Item // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as map from 'lib0/map.js'
|
||||
import * as set from 'lib0/set.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
export class Snapshot {
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {Map<number,number>} sm state map
|
||||
* @param {Map<number,number>} sv state map
|
||||
*/
|
||||
constructor (ds, sm) {
|
||||
constructor (ds, sv) {
|
||||
/**
|
||||
* @type {DeleteSet}
|
||||
* @private
|
||||
*/
|
||||
this.ds = ds
|
||||
/**
|
||||
* State Map
|
||||
* @type {Map<number,number>}
|
||||
* @private
|
||||
*/
|
||||
this.sm = sm
|
||||
this.sv = sv
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snap1
|
||||
* @param {Snapshot} snap2
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const equalSnapshots = (snap1, snap2) => {
|
||||
const ds1 = snap1.ds.clients
|
||||
const ds2 = snap2.ds.clients
|
||||
const sv1 = snap1.sv
|
||||
const sv2 = snap2.sv
|
||||
if (sv1.size !== sv2.size || ds1.size !== ds2.size) {
|
||||
return false
|
||||
}
|
||||
for (const [key, value] of sv1) {
|
||||
if (sv2.get(key) !== value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (const [client, dsitems1] of ds1) {
|
||||
const dsitems2 = ds2.get(client) || []
|
||||
if (dsitems1.length !== dsitems2.length) {
|
||||
return false
|
||||
}
|
||||
for (let i = 0; i < dsitems1.length; i++) {
|
||||
const dsitem1 = dsitems1[i]
|
||||
const dsitem2 = dsitems2[i]
|
||||
if (dsitem1.clock !== dsitem2.clock || dsitem1.len !== dsitem2.len) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Snapshot} snapshot
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const encodeSnapshot = snapshot => {
|
||||
const encoder = encoding.createEncoder()
|
||||
writeDeleteSet(encoder, snapshot.ds)
|
||||
writeStateVector(encoder, snapshot.sv)
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} buf
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const decodeSnapshot = buf => {
|
||||
const decoder = decoding.createDecoder(buf)
|
||||
return new Snapshot(readDeleteSet(decoder), readStateVector(decoder))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {Map<number,number>} sm
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
|
||||
|
||||
export const emptySnapshot = createSnapshot(createDeleteSet(), new Map())
|
||||
|
||||
/**
|
||||
* @param {Doc} doc
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store))
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @param {Snapshot|undefined} snapshot
|
||||
@@ -38,5 +115,24 @@ export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
|
||||
* @function
|
||||
*/
|
||||
export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : (
|
||||
snapshot.sm.has(item.id.client) && (snapshot.sm.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
|
||||
snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Snapshot} snapshot
|
||||
*/
|
||||
export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
|
||||
const meta = map.setIfUndefined(transaction.meta, splitSnapshotAffectedStructs, set.create)
|
||||
const store = transaction.doc.store
|
||||
// check if we already split for this snapshot
|
||||
if (!meta.has(snapshot)) {
|
||||
snapshot.sv.forEach((clock, client) => {
|
||||
if (clock < getState(store, client)) {
|
||||
getItemCleanStart(transaction, createID(client, clock))
|
||||
}
|
||||
})
|
||||
iterateDeletedStructs(transaction, snapshot.ds, item => {})
|
||||
meta.add(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {
|
||||
GC,
|
||||
splitItem,
|
||||
GCRef, ItemRef, Transaction, ID, Item, AbstractStruct // eslint-disable-line
|
||||
AbstractStruct, Transaction, ID, Item // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as math from 'lib0/math.js'
|
||||
@@ -12,8 +12,7 @@ import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
export class StructStore {
|
||||
constructor () {
|
||||
/**
|
||||
* @type {Map<number,Array<AbstractStruct>>}
|
||||
* @private
|
||||
* @type {Map<number,Array<GC|Item>>}
|
||||
*/
|
||||
this.clients = new Map()
|
||||
/**
|
||||
@@ -22,20 +21,17 @@ export class StructStore {
|
||||
* We could shift the array of refs instead, but shift is incredible
|
||||
* slow in Chrome for arrays with more than 100k elements
|
||||
* @see tryResumePendingStructRefs
|
||||
* @type {Map<number,{i:number,refs:Array<GCRef|ItemRef>}>}
|
||||
* @private
|
||||
* @type {Map<number,{i:number,refs:Array<GC|Item>}>}
|
||||
*/
|
||||
this.pendingClientsStructRefs = new Map()
|
||||
/**
|
||||
* Stack of pending structs waiting for struct dependencies
|
||||
* Maximum length of stack is structReaders.size
|
||||
* @type {Array<GCRef|ItemRef>}
|
||||
* @private
|
||||
* @type {Array<GC|Item>}
|
||||
*/
|
||||
this.pendingStack = []
|
||||
/**
|
||||
* @type {Array<decoding.Decoder>}
|
||||
* @private
|
||||
*/
|
||||
this.pendingDeleteReaders = []
|
||||
}
|
||||
@@ -97,7 +93,7 @@ export const integretyCheck = store => {
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
* @param {AbstractStruct} struct
|
||||
* @param {GC|Item} struct
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
@@ -118,7 +114,7 @@ export const addStruct = (store, struct) => {
|
||||
|
||||
/**
|
||||
* Perform a binary search on a sorted array
|
||||
* @param {Array<any>} structs
|
||||
* @param {Array<Item|GC>} structs
|
||||
* @param {number} clock
|
||||
* @return {number}
|
||||
*
|
||||
@@ -128,10 +124,18 @@ export const addStruct = (store, struct) => {
|
||||
export const findIndexSS = (structs, clock) => {
|
||||
let left = 0
|
||||
let right = structs.length - 1
|
||||
let mid = structs[right]
|
||||
let midclock = mid.id.clock
|
||||
if (mid.id.clock === clock) {
|
||||
return right
|
||||
}
|
||||
// @todo does it even make sense to pivot the search?
|
||||
// If a good split misses, it might actually increase the time to find the correct item.
|
||||
// Currently, the only advantage is that search with pivoting might find the item on the first try.
|
||||
let midindex = math.floor((clock / (midclock + mid.length - 1)) * right) // pivoting the search
|
||||
while (left <= right) {
|
||||
const midindex = math.floor((left + right) / 2)
|
||||
const mid = structs[midindex]
|
||||
const midclock = mid.id.clock
|
||||
mid = structs[midindex]
|
||||
midclock = mid.id.clock
|
||||
if (midclock <= clock) {
|
||||
if (clock < midclock + mid.length) {
|
||||
return midindex
|
||||
@@ -140,6 +144,7 @@ export const findIndexSS = (structs, clock) => {
|
||||
} else {
|
||||
right = midindex - 1
|
||||
}
|
||||
midindex = math.floor((left + right) / 2)
|
||||
}
|
||||
// Always check state before looking for a struct in StructStore
|
||||
// Therefore the case of not finding a struct is unexpected
|
||||
@@ -151,14 +156,14 @@ export const findIndexSS = (structs, clock) => {
|
||||
*
|
||||
* @param {StructStore} store
|
||||
* @param {ID} id
|
||||
* @return {AbstractStruct}
|
||||
* @return {GC|Item}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const find = (store, id) => {
|
||||
/**
|
||||
* @type {Array<AbstractStruct>}
|
||||
* @type {Array<GC|Item>}
|
||||
*/
|
||||
// @ts-ignore
|
||||
const structs = store.clients.get(id.client)
|
||||
@@ -167,37 +172,39 @@ export const find = (store, id) => {
|
||||
|
||||
/**
|
||||
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||
*
|
||||
* @param {StructStore} store
|
||||
* @param {ID} id
|
||||
* @return {Item}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
// @ts-ignore
|
||||
export const getItem = (store, id) => find(store, id)
|
||||
export const getItem = /** @type {function(StructStore,ID):Item} */ (find)
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {Array<Item|GC>} structs
|
||||
* @param {number} clock
|
||||
*/
|
||||
export const findIndexCleanStart = (transaction, structs, clock) => {
|
||||
const index = findIndexSS(structs, clock)
|
||||
const struct = structs[index]
|
||||
if (struct.id.clock < clock && struct instanceof Item) {
|
||||
structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock))
|
||||
return index + 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 getItemCleanStart = (transaction, store, id) => {
|
||||
const structs = /** @type {Array<Item>} */ (store.clients.get(id.client))
|
||||
const index = findIndexSS(structs, id.clock)
|
||||
let struct = structs[index]
|
||||
if (struct.id.clock < id.clock && struct.constructor !== GC) {
|
||||
struct = splitItem(transaction, struct, id.clock - struct.id.clock)
|
||||
structs.splice(index + 1, 0, struct)
|
||||
}
|
||||
return struct
|
||||
export const getItemCleanStart = (transaction, id) => {
|
||||
const structs = /** @type {Array<Item>} */ (transaction.doc.store.clients.get(id.client))
|
||||
return structs[findIndexCleanStart(transaction, structs, id.clock)]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,13 +235,40 @@ export const getItemCleanEnd = (transaction, store, id) => {
|
||||
/**
|
||||
* Replace `item` with `newitem` in store
|
||||
* @param {StructStore} store
|
||||
* @param {AbstractStruct} struct
|
||||
* @param {AbstractStruct} newStruct
|
||||
* @param {GC|Item} struct
|
||||
* @param {GC|Item} newStruct
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const replaceStruct = (store, struct, newStruct) => {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(struct.id.client))
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(struct.id.client))
|
||||
structs[findIndexSS(structs, struct.id.clock)] = newStruct
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over a range of structs
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {Array<Item|GC>} structs
|
||||
* @param {number} clockStart Inclusive start
|
||||
* @param {number} len
|
||||
* @param {function(GC|Item):void} f
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const iterateStructs = (transaction, structs, clockStart, len, f) => {
|
||||
if (len === 0) {
|
||||
return
|
||||
}
|
||||
const clockEnd = clockStart + len
|
||||
let index = findIndexCleanStart(transaction, structs, clockStart)
|
||||
let struct
|
||||
do {
|
||||
struct = structs[index++]
|
||||
if (clockEnd < struct.id.clock + struct.length) {
|
||||
findIndexCleanStart(transaction, structs, clockEnd)
|
||||
}
|
||||
f(struct)
|
||||
} while (index < structs.length && structs[index].id.clock < clockEnd)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
import {
|
||||
getState,
|
||||
createID,
|
||||
writeStructsFromTransaction,
|
||||
writeDeleteSet,
|
||||
DeleteSet,
|
||||
@@ -10,12 +9,17 @@ import {
|
||||
findIndexSS,
|
||||
callEventHandlerListeners,
|
||||
Item,
|
||||
ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
|
||||
generateNewClientId,
|
||||
createID,
|
||||
GC, StructStore, ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as map from 'lib0/map.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
import * as set from 'lib0/set.js'
|
||||
import * as logging from 'lib0/logging.js'
|
||||
import { callAll } from 'lib0/function.js'
|
||||
|
||||
/**
|
||||
* A transaction is created for every change on the Yjs model. It is possible
|
||||
@@ -45,8 +49,9 @@ export class Transaction {
|
||||
/**
|
||||
* @param {Doc} doc
|
||||
* @param {any} origin
|
||||
* @param {boolean} local
|
||||
*/
|
||||
constructor (doc, origin) {
|
||||
constructor (doc, origin, local) {
|
||||
/**
|
||||
* The Yjs instance.
|
||||
* @type {Doc}
|
||||
@@ -70,7 +75,7 @@ export class Transaction {
|
||||
/**
|
||||
* All types that were directly modified (property added or child
|
||||
* inserted/deleted). New types are not included in this Set.
|
||||
* Maps from type to parentSubs (`item._parentSub = null` for YArray)
|
||||
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
|
||||
* @type {Map<AbstractType<YEvent>,Set<String|null>>}
|
||||
*/
|
||||
this.changed = new Map()
|
||||
@@ -81,14 +86,23 @@ export class Transaction {
|
||||
*/
|
||||
this.changedParentTypes = new Map()
|
||||
/**
|
||||
* @type {Set<ID>}
|
||||
* @private
|
||||
* @type {Array<AbstractStruct>}
|
||||
*/
|
||||
this._mergeStructs = new Set()
|
||||
this._mergeStructs = []
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
this.origin = origin
|
||||
/**
|
||||
* Stores meta information on the transaction
|
||||
* @type {Map<any,any>}
|
||||
*/
|
||||
this.meta = new Map()
|
||||
/**
|
||||
* Whether this change originates from this doc.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.local = local
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,22 +131,226 @@ export const nextID = transaction => {
|
||||
return createID(y.clientID, getState(y.store, y.clientID))
|
||||
}
|
||||
|
||||
/**
|
||||
* If `type.parent` was added in current transaction, `type` technically
|
||||
* did not change, it was just added and we should not fire events for `type`.
|
||||
*
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<YEvent>} type
|
||||
* @param {string|null} parentSub
|
||||
*/
|
||||
export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
|
||||
const item = type._item
|
||||
if (item === null || (item.id.clock < (transaction.beforeState.get(item.id.client) || 0) && !item.deleted)) {
|
||||
map.setIfUndefined(transaction.changed, type, set.create).add(parentSub)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<AbstractStruct>} structs
|
||||
* @param {number} pos
|
||||
*/
|
||||
const tryToMergeWithLeft = (structs, pos) => {
|
||||
const left = structs[pos - 1]
|
||||
const right = structs[pos]
|
||||
if (left.deleted === right.deleted && left.constructor === right.constructor) {
|
||||
if (left.mergeWith(right)) {
|
||||
structs.splice(pos, 1)
|
||||
if (right instanceof Item && right.parentSub !== null && /** @type {AbstractType<any>} */ (right.parent)._map.get(right.parentSub) === right) {
|
||||
/** @type {AbstractType<any>} */ (right.parent)._map.set(right.parentSub, /** @type {Item} */ (left))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
* @param {function(Item):boolean} gcFilter
|
||||
*/
|
||||
const tryGcDeleteSet = (ds, store, gcFilter) => {
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
const endDeleteItemClock = deleteItem.clock + deleteItem.len
|
||||
for (
|
||||
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
|
||||
si < structs.length && struct.id.clock < endDeleteItemClock;
|
||||
struct = structs[++si]
|
||||
) {
|
||||
const struct = structs[si]
|
||||
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||
break
|
||||
}
|
||||
if (struct instanceof Item && struct.deleted && !struct.keep && gcFilter(struct)) {
|
||||
struct.gc(store, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
const tryMergeDeleteSet = (ds, store) => {
|
||||
// try to merge deleted / gc'd items
|
||||
// merge from right to left for better efficiecy and so we don't miss any merge targets
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
// start with merging the item next to the last deleted item
|
||||
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
|
||||
for (
|
||||
let si = mostRightIndexToCheck, struct = structs[si];
|
||||
si > 0 && struct.id.clock >= deleteItem.clock;
|
||||
struct = structs[--si]
|
||||
) {
|
||||
tryToMergeWithLeft(structs, si)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
* @param {function(Item):boolean} gcFilter
|
||||
*/
|
||||
export const tryGc = (ds, store, gcFilter) => {
|
||||
tryGcDeleteSet(ds, store, gcFilter)
|
||||
tryMergeDeleteSet(ds, store)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Transaction>} transactionCleanups
|
||||
* @param {number} i
|
||||
*/
|
||||
const cleanupTransactions = (transactionCleanups, i) => {
|
||||
if (i < transactionCleanups.length) {
|
||||
const transaction = transactionCleanups[i]
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ds = transaction.deleteSet
|
||||
const mergeStructs = transaction._mergeStructs
|
||||
try {
|
||||
sortAndMergeDeleteSet(ds)
|
||||
transaction.afterState = getStateVector(transaction.doc.store)
|
||||
doc._transaction = null
|
||||
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||
/**
|
||||
* An array of event callbacks.
|
||||
*
|
||||
* Each callback is called even if the other ones throw errors.
|
||||
*
|
||||
* @type {Array<function():void>}
|
||||
*/
|
||||
const fs = []
|
||||
// observe events on changed types
|
||||
transaction.changed.forEach((subs, itemtype) =>
|
||||
fs.push(() => {
|
||||
if (itemtype._item === null || !itemtype._item.deleted) {
|
||||
itemtype._callObserver(transaction, subs)
|
||||
}
|
||||
})
|
||||
)
|
||||
fs.push(() => {
|
||||
// deep observe events
|
||||
transaction.changedParentTypes.forEach((events, type) =>
|
||||
fs.push(() => {
|
||||
// We need to think about the possibility that the user transforms the
|
||||
// Y.Doc in the event.
|
||||
if (type._item === null || !type._item.deleted) {
|
||||
events = events
|
||||
.filter(event =>
|
||||
event.target._item === null || !event.target._item.deleted
|
||||
)
|
||||
events
|
||||
.forEach(event => {
|
||||
event.currentTarget = type
|
||||
})
|
||||
// We don't need to check for events.length
|
||||
// because we know it has at least one element
|
||||
callEventHandlerListeners(type._dEH, events, transaction)
|
||||
}
|
||||
})
|
||||
)
|
||||
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
|
||||
})
|
||||
callAll(fs, [])
|
||||
} finally {
|
||||
// Replace deleted items with ItemDeleted / GC.
|
||||
// This is where content is actually remove from the Yjs Doc.
|
||||
if (doc.gc) {
|
||||
tryGcDeleteSet(ds, store, doc.gcFilter)
|
||||
}
|
||||
tryMergeDeleteSet(ds, store)
|
||||
|
||||
// on all affected store.clients props, try to merge
|
||||
for (const [client, clock] of transaction.afterState) {
|
||||
const beforeClock = transaction.beforeState.get(client) || 0
|
||||
if (beforeClock !== clock) {
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
// we iterate from right to left so we can safely remove entries
|
||||
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
|
||||
for (let i = structs.length - 1; i >= firstChangePos; i--) {
|
||||
tryToMergeWithLeft(structs, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge mergeStructs
|
||||
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
|
||||
// but at the moment DS does not handle duplicates
|
||||
for (let i = 0; i < mergeStructs.length; i++) {
|
||||
const { client, clock } = mergeStructs[i].id
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
const replacedStructPos = findIndexSS(structs, clock)
|
||||
if (replacedStructPos + 1 < structs.length) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos + 1)
|
||||
}
|
||||
if (replacedStructPos > 0) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos)
|
||||
}
|
||||
}
|
||||
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.')
|
||||
}
|
||||
// @todo Merge all the transactions into one and provide send the data as a single update message
|
||||
doc.emit('afterTransactionCleanup', [transaction, doc])
|
||||
if (doc._observers.has('update')) {
|
||||
const updateMessage = computeUpdateMessageFromTransaction(transaction)
|
||||
if (updateMessage !== null) {
|
||||
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
|
||||
}
|
||||
}
|
||||
if (transactionCleanups.length <= i + 1) {
|
||||
doc._transactionCleanups = []
|
||||
} else {
|
||||
cleanupTransactions(transactionCleanups, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the functionality of `y.transact(()=>{..})`
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @param {function(Transaction):void} f
|
||||
* @param {any} [origin]
|
||||
* @param {any} [origin=true]
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const transact = (doc, f, origin = null) => {
|
||||
export const transact = (doc, f, origin = null, local = true) => {
|
||||
const transactionCleanups = doc._transactionCleanups
|
||||
let initialCall = false
|
||||
if (doc._transaction === null) {
|
||||
initialCall = true
|
||||
doc._transaction = new Transaction(doc, origin)
|
||||
doc._transaction = new Transaction(doc, origin, local)
|
||||
transactionCleanups.push(doc._transaction)
|
||||
doc.emit('beforeTransaction', [doc._transaction, doc])
|
||||
}
|
||||
@@ -142,125 +360,13 @@ export const transact = (doc, f, origin = null) => {
|
||||
if (initialCall && transactionCleanups[0] === doc._transaction) {
|
||||
// The first transaction ended, now process observer calls.
|
||||
// Observer call may create new transactions for which we need to call the observers and do cleanup.
|
||||
// We don't want to nest these calls, so we execute these calls one after another
|
||||
for (let i = 0; i < transactionCleanups.length; i++) {
|
||||
const transaction = transactionCleanups[i]
|
||||
const store = transaction.doc.store
|
||||
const ds = transaction.deleteSet
|
||||
sortAndMergeDeleteSet(ds)
|
||||
transaction.afterState = getStateVector(transaction.doc.store)
|
||||
doc._transaction = null
|
||||
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||
// emit change events on changed types
|
||||
transaction.changed.forEach((subs, itemtype) => {
|
||||
itemtype._callObserver(transaction, subs)
|
||||
})
|
||||
transaction.changedParentTypes.forEach((events, type) => {
|
||||
events = events
|
||||
.filter(event =>
|
||||
event.target._item === null || !event.target._item.deleted
|
||||
)
|
||||
events
|
||||
.forEach(event => {
|
||||
event.currentTarget = type
|
||||
})
|
||||
// we don't need to check for events.length
|
||||
// because we know it has at least one element
|
||||
callEventHandlerListeners(type._dEH, events, transaction)
|
||||
})
|
||||
doc.emit('afterTransaction', [transaction, doc])
|
||||
/**
|
||||
* @param {Array<AbstractStruct>} structs
|
||||
* @param {number} pos
|
||||
*/
|
||||
const tryToMergeWithLeft = (structs, pos) => {
|
||||
const left = structs[pos - 1]
|
||||
const right = structs[pos]
|
||||
if (left.deleted === right.deleted && left.constructor === right.constructor) {
|
||||
if (left.mergeWith(right)) {
|
||||
structs.splice(pos, 1)
|
||||
if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
|
||||
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// replace deleted items with ItemDeleted / GC
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
const endDeleteItemClock = deleteItem.clock + deleteItem.len
|
||||
for (
|
||||
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
|
||||
si < structs.length && struct.id.clock < endDeleteItemClock;
|
||||
struct = structs[++si]
|
||||
) {
|
||||
const struct = structs[si]
|
||||
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||
break
|
||||
}
|
||||
if (struct.deleted && struct instanceof Item) {
|
||||
struct.gc(store, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge deleted / gc'd items
|
||||
// merge from right to left for better efficiecy and so we don't miss any merge targets
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
// start with merging the item next to the last deleted item
|
||||
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
|
||||
for (
|
||||
let si = mostRightIndexToCheck, struct = structs[si];
|
||||
si > 0 && struct.id.clock >= deleteItem.clock;
|
||||
struct = structs[--si]
|
||||
) {
|
||||
tryToMergeWithLeft(structs, si)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// on all affected store.clients props, try to merge
|
||||
for (const [client, clock] of transaction.afterState) {
|
||||
const beforeClock = transaction.beforeState.get(client) || 0
|
||||
if (beforeClock !== clock) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
// we iterate from right to left so we can safely remove entries
|
||||
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
|
||||
for (let i = structs.length - 1; i >= firstChangePos; i--) {
|
||||
tryToMergeWithLeft(structs, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge mergeStructs
|
||||
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
|
||||
// but at the moment DS does not handle duplicates
|
||||
for (const mid of transaction._mergeStructs) {
|
||||
const client = mid.client
|
||||
const clock = mid.clock
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
const replacedStructPos = findIndexSS(structs, clock)
|
||||
if (replacedStructPos + 1 < structs.length) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos + 1)
|
||||
}
|
||||
if (replacedStructPos > 0) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos)
|
||||
}
|
||||
}
|
||||
// @todo Merge all the transactions into one and provide send the data as a single update message
|
||||
doc.emit('afterTransactionCleanup', [transaction, doc])
|
||||
if (doc._observers.has('update')) {
|
||||
const updateMessage = computeUpdateMessageFromTransaction(transaction)
|
||||
if (updateMessage !== null) {
|
||||
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
|
||||
}
|
||||
}
|
||||
}
|
||||
doc._transactionCleanups = []
|
||||
// We don't want to nest these calls, so we execute these calls one after
|
||||
// another.
|
||||
// Also we need to ensure that all cleanups are called, even if the
|
||||
// observes throw errors.
|
||||
// This file is full of hacky try {} finally {} blocks to ensure that an
|
||||
// event can throw errors and also that the cleanup is called.
|
||||
cleanupTransactions(transactionCleanups, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,202 +1,293 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import {
|
||||
isParentOf,
|
||||
mergeDeleteSets,
|
||||
iterateDeletedStructs,
|
||||
keepItem,
|
||||
transact,
|
||||
createID,
|
||||
transact
|
||||
redoItem,
|
||||
iterateStructs,
|
||||
isParentOf,
|
||||
followRedone,
|
||||
getItemCleanStart,
|
||||
getState,
|
||||
ID, Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
class ReverseOperation {
|
||||
constructor (y, transaction, bindingInfos) {
|
||||
this.created = new Date()
|
||||
const beforeState = transaction.beforeState
|
||||
if (beforeState.has(y.userID)) {
|
||||
this.toState = createID(y.userID, y.ss.getState(y.userID) - 1)
|
||||
this.fromState = createID(y.userID, beforeState.get(y.userID))
|
||||
} else {
|
||||
this.toState = null
|
||||
this.fromState = null
|
||||
}
|
||||
this.deletedStructs = new Set()
|
||||
transaction.deletedStructs.forEach(struct => {
|
||||
this.deletedStructs.add({
|
||||
from: struct._id,
|
||||
len: struct._length
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Maps from binding to binding information (e.g. cursor information)
|
||||
*/
|
||||
this.bindingInfos = bindingInfos
|
||||
}
|
||||
}
|
||||
import * as time from 'lib0/time.js'
|
||||
import { Observable } from 'lib0/observable.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
function applyReverseOperation (y, scope, reverseBuffer) {
|
||||
let performedUndo = false
|
||||
let undoOp = null
|
||||
transact(y, () => {
|
||||
while (!performedUndo && reverseBuffer.length > 0) {
|
||||
undoOp = reverseBuffer.pop()
|
||||
// make sure that it is possible to iterate {from}-{to}
|
||||
if (undoOp.fromState !== null) {
|
||||
y.os.getItemCleanStart(undoOp.fromState)
|
||||
y.os.getItemCleanEnd(undoOp.toState)
|
||||
y.os.iterate(undoOp.fromState, undoOp.toState, op => {
|
||||
while (op._deleted && op._redone !== null) {
|
||||
op = op._redone
|
||||
}
|
||||
if (op._deleted === false && isParentOf(scope, op)) {
|
||||
performedUndo = true
|
||||
op._delete(y)
|
||||
}
|
||||
})
|
||||
}
|
||||
const redoitems = new Set()
|
||||
for (let del of undoOp.deletedStructs) {
|
||||
const fromState = del.from
|
||||
const toState = createID(fromState.user, fromState.clock + del.len - 1)
|
||||
y.os.getItemCleanStart(fromState)
|
||||
y.os.getItemCleanEnd(toState)
|
||||
y.os.iterate(fromState, toState, op => {
|
||||
if (
|
||||
isParentOf(scope, op) &&
|
||||
op._parent !== y &&
|
||||
(
|
||||
op._id.user !== y.userID ||
|
||||
undoOp.fromState === null ||
|
||||
op._id.clock < undoOp.fromState.clock ||
|
||||
op._id.clock > undoOp.toState.clock
|
||||
)
|
||||
) {
|
||||
redoitems.add(op)
|
||||
}
|
||||
})
|
||||
}
|
||||
redoitems.forEach(op => {
|
||||
const opUndone = op._redo(y, redoitems)
|
||||
performedUndo = performedUndo || opUndone
|
||||
})
|
||||
}
|
||||
})
|
||||
if (performedUndo && undoOp !== null) {
|
||||
// should be performed after the undo transaction
|
||||
undoOp.bindingInfos.forEach((info, binding) => {
|
||||
binding._restoreUndoStackInfo(info)
|
||||
})
|
||||
}
|
||||
return performedUndo
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a history of locally applied operations. The UndoManager handles the
|
||||
* undoing and redoing of locally created changes.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export class UndoManager {
|
||||
class StackItem {
|
||||
/**
|
||||
* @param {YType} scope The scope on which to listen for changes.
|
||||
* @param {Object} options Optionally provided configuration.
|
||||
* @param {DeleteSet} ds
|
||||
* @param {Map<number,number>} beforeState
|
||||
* @param {Map<number,number>} afterState
|
||||
*/
|
||||
constructor (scope, options = {}) {
|
||||
this.options = options
|
||||
this._bindings = new Set(options.bindings)
|
||||
options.captureTimeout = options.captureTimeout == null ? 500 : options.captureTimeout
|
||||
this._undoBuffer = []
|
||||
this._redoBuffer = []
|
||||
this._scope = scope
|
||||
this._undoing = false
|
||||
this._redoing = false
|
||||
this._lastTransactionWasUndo = false
|
||||
const doc = scope.doc
|
||||
this.y = doc
|
||||
let bindingInfos
|
||||
doc.on('beforeTransaction', (y, transaction, remote) => {
|
||||
if (!remote) {
|
||||
// Store binding information before transaction is executed
|
||||
// By restoring the binding information, we can make sure that the state
|
||||
// before the transaction can be recovered
|
||||
bindingInfos = new Map()
|
||||
this._bindings.forEach(binding => {
|
||||
bindingInfos.set(binding, binding._getUndoStackInfo())
|
||||
})
|
||||
}
|
||||
})
|
||||
doc.on('afterTransaction', (y, transaction, remote) => {
|
||||
if (!remote && transaction.changedParentTypes.has(scope)) {
|
||||
let reverseOperation = new ReverseOperation(y, transaction, bindingInfos)
|
||||
if (!this._undoing) {
|
||||
let lastUndoOp = this._undoBuffer.length > 0 ? this._undoBuffer[this._undoBuffer.length - 1] : null
|
||||
if (
|
||||
this._redoing === false &&
|
||||
this._lastTransactionWasUndo === false &&
|
||||
lastUndoOp !== null &&
|
||||
((options.captureTimeout < 0) || (reverseOperation.created.getTime() - lastUndoOp.created.getTime()) <= options.captureTimeout)
|
||||
) {
|
||||
lastUndoOp.created = reverseOperation.created
|
||||
if (reverseOperation.toState !== null) {
|
||||
lastUndoOp.toState = reverseOperation.toState
|
||||
if (lastUndoOp.fromState === null) {
|
||||
lastUndoOp.fromState = reverseOperation.fromState
|
||||
constructor (ds, beforeState, afterState) {
|
||||
this.ds = ds
|
||||
this.beforeState = beforeState
|
||||
this.afterState = afterState
|
||||
/**
|
||||
* Use this to save and restore metadata like selection range
|
||||
*/
|
||||
this.meta = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {UndoManager} undoManager
|
||||
* @param {Array<StackItem>} stack
|
||||
* @param {string} eventType
|
||||
* @return {StackItem?}
|
||||
*/
|
||||
const popStackItem = (undoManager, stack, eventType) => {
|
||||
/**
|
||||
* Whether a change happened
|
||||
* @type {StackItem?}
|
||||
*/
|
||||
let result = null
|
||||
const doc = undoManager.doc
|
||||
const scope = undoManager.scope
|
||||
transact(doc, transaction => {
|
||||
while (stack.length > 0 && result === null) {
|
||||
const store = doc.store
|
||||
const stackItem = /** @type {StackItem} */ (stack.pop())
|
||||
/**
|
||||
* @type {Set<Item>}
|
||||
*/
|
||||
const itemsToRedo = new Set()
|
||||
/**
|
||||
* @type {Array<Item>}
|
||||
*/
|
||||
const itemsToDelete = []
|
||||
let performedChange = false
|
||||
stackItem.afterState.forEach((endClock, client) => {
|
||||
const startClock = stackItem.beforeState.get(client) || 0
|
||||
const len = endClock - startClock
|
||||
// @todo iterateStructs should not need the structs parameter
|
||||
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||
if (startClock !== endClock) {
|
||||
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end)
|
||||
// this must be executed before deleted structs are iterated.
|
||||
getItemCleanStart(transaction, createID(client, startClock))
|
||||
if (endClock < getState(doc.store, client)) {
|
||||
getItemCleanStart(transaction, createID(client, endClock))
|
||||
}
|
||||
iterateStructs(transaction, structs, startClock, len, struct => {
|
||||
if (struct instanceof Item) {
|
||||
if (struct.redone !== null) {
|
||||
let { item, diff } = followRedone(store, struct.id)
|
||||
if (diff > 0) {
|
||||
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
|
||||
}
|
||||
if (item.length > len) {
|
||||
getItemCleanStart(transaction, createID(item.id.client, endClock))
|
||||
}
|
||||
struct = item
|
||||
}
|
||||
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
|
||||
itemsToDelete.push(struct)
|
||||
}
|
||||
}
|
||||
reverseOperation.deletedStructs.forEach(lastUndoOp.deletedStructs.add, lastUndoOp.deletedStructs)
|
||||
} else {
|
||||
this._lastTransactionWasUndo = false
|
||||
this._undoBuffer.push(reverseOperation)
|
||||
}
|
||||
if (!this._redoing) {
|
||||
this._redoBuffer = []
|
||||
}
|
||||
} else {
|
||||
this._lastTransactionWasUndo = true
|
||||
this._redoBuffer.push(reverseOperation)
|
||||
})
|
||||
}
|
||||
})
|
||||
iterateDeletedStructs(transaction, stackItem.ds, struct => {
|
||||
const id = struct.id
|
||||
const clock = id.clock
|
||||
const client = id.client
|
||||
const startClock = stackItem.beforeState.get(client) || 0
|
||||
const endClock = stackItem.afterState.get(client) || 0
|
||||
if (
|
||||
struct instanceof Item &&
|
||||
scope.some(type => isParentOf(type, struct)) &&
|
||||
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
|
||||
!(clock >= startClock && clock < endClock)
|
||||
) {
|
||||
itemsToRedo.add(struct)
|
||||
}
|
||||
})
|
||||
itemsToRedo.forEach(struct => {
|
||||
performedChange = redoItem(transaction, struct, itemsToRedo) !== 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.
|
||||
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
|
||||
const item = itemsToDelete[i]
|
||||
if (undoManager.deleteFilter(item)) {
|
||||
item.delete(transaction)
|
||||
performedChange = true
|
||||
}
|
||||
}
|
||||
result = stackItem
|
||||
if (result != null) {
|
||||
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
|
||||
}
|
||||
}
|
||||
}, undoManager)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UndoManagerOptions
|
||||
* @property {number} [UndoManagerOptions.captureTimeout=500]
|
||||
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
|
||||
* it is necessary to filter whan an Undo/Redo operation can delete. If this
|
||||
* filter returns false, the type/item won't be deleted even it is in the
|
||||
* undo/redo scope.
|
||||
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fires 'stack-item-added' event when a stack item was added to either the undo- or
|
||||
* the redo-stack. You may store additional stack information via the
|
||||
* metadata property on `event.stackItem.meta` (it is a `Map` of metadata properties).
|
||||
* Fires 'stack-item-popped' event when a stack item was popped from either the
|
||||
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
|
||||
*
|
||||
* @extends {Observable<'stack-item-added'|'stack-item-popped'>}
|
||||
*/
|
||||
export class UndoManager extends Observable {
|
||||
/**
|
||||
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
|
||||
* @param {UndoManagerOptions} options
|
||||
*/
|
||||
constructor (typeScope, { captureTimeout, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
|
||||
if (captureTimeout == null) {
|
||||
captureTimeout = 500
|
||||
}
|
||||
super()
|
||||
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
|
||||
this.deleteFilter = deleteFilter
|
||||
trackedOrigins.add(this)
|
||||
this.trackedOrigins = trackedOrigins
|
||||
/**
|
||||
* @type {Array<StackItem>}
|
||||
*/
|
||||
this.undoStack = []
|
||||
/**
|
||||
* @type {Array<StackItem>}
|
||||
*/
|
||||
this.redoStack = []
|
||||
/**
|
||||
* Whether the client is currently undoing (calling UndoManager.undo)
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.undoing = false
|
||||
this.redoing = false
|
||||
this.doc = /** @type {Doc} */ (this.scope[0].doc)
|
||||
this.lastChange = 0
|
||||
this.doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
|
||||
// Only track certain transactions
|
||||
if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))) {
|
||||
return
|
||||
}
|
||||
const undoing = this.undoing
|
||||
const redoing = this.redoing
|
||||
const stack = undoing ? this.redoStack : this.undoStack
|
||||
if (undoing) {
|
||||
this.stopCapturing() // next undo should not be appended to last stack item
|
||||
} else if (!redoing) {
|
||||
// neither undoing nor redoing: delete redoStack
|
||||
this.redoStack = []
|
||||
}
|
||||
const beforeState = transaction.beforeState
|
||||
const afterState = transaction.afterState
|
||||
const now = time.getUnixTime()
|
||||
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
|
||||
// append change to last stack op
|
||||
const lastOp = stack[stack.length - 1]
|
||||
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
|
||||
lastOp.afterState = afterState
|
||||
} else {
|
||||
// create a new stack op
|
||||
stack.push(new StackItem(transaction.deleteSet, beforeState, afterState))
|
||||
}
|
||||
if (!undoing && !redoing) {
|
||||
this.lastChange = now
|
||||
}
|
||||
// make sure that deleted structs are not gc'd
|
||||
iterateDeletedStructs(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
|
||||
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
|
||||
keepItem(item, true)
|
||||
}
|
||||
})
|
||||
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo' }, this])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce that the next change is created as a separate item in the undo stack
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
flushChanges () {
|
||||
this._lastTransactionWasUndo = true
|
||||
clear () {
|
||||
this.doc.transact(transaction => {
|
||||
/**
|
||||
* @param {StackItem} stackItem
|
||||
*/
|
||||
const clearItem = stackItem => {
|
||||
iterateDeletedStructs(transaction, stackItem.ds, item => {
|
||||
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
|
||||
keepItem(item, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
this.undoStack.forEach(clearItem)
|
||||
this.redoStack.forEach(clearItem)
|
||||
})
|
||||
this.undoStack = []
|
||||
this.redoStack = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the last locally created change.
|
||||
* UndoManager merges Undo-StackItem if they are created within time-gap
|
||||
* smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next
|
||||
* StackItem won't be merged.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*
|
||||
* @example
|
||||
* // without stopCapturing
|
||||
* ytext.insert(0, 'a')
|
||||
* ytext.insert(1, 'b')
|
||||
* um.undo()
|
||||
* ytext.toString() // => '' (note that 'ab' was removed)
|
||||
* // with stopCapturing
|
||||
* ytext.insert(0, 'a')
|
||||
* um.stopCapturing()
|
||||
* ytext.insert(0, 'b')
|
||||
* um.undo()
|
||||
* ytext.toString() // => 'a' (note that only 'b' was removed)
|
||||
*
|
||||
*/
|
||||
stopCapturing () {
|
||||
this.lastChange = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo last changes on type.
|
||||
*
|
||||
* @return {StackItem?} Returns StackItem if a change was applied
|
||||
*/
|
||||
undo () {
|
||||
this._undoing = true
|
||||
const performedUndo = applyReverseOperation(this.y, this._scope, this._undoBuffer)
|
||||
this._undoing = false
|
||||
return performedUndo
|
||||
this.undoing = true
|
||||
let res
|
||||
try {
|
||||
res = popStackItem(this, this.undoStack, 'undo')
|
||||
} finally {
|
||||
this.undoing = false
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo the last locally created change.
|
||||
* Redo last undo operation.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
* @return {StackItem?} Returns StackItem if a change was applied
|
||||
*/
|
||||
redo () {
|
||||
this._redoing = true
|
||||
const performedRedo = applyReverseOperation(this.y, this._scope, this._redoBuffer)
|
||||
this._redoing = false
|
||||
return performedRedo
|
||||
this.redoing = true
|
||||
let res
|
||||
try {
|
||||
res = popStackItem(this, this.redoStack, 'redo')
|
||||
} finally {
|
||||
this.redoing = false
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
|
||||
import {
|
||||
isDeleted,
|
||||
AbstractType, Transaction, AbstractStruct // eslint-disable-line
|
||||
Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as set from 'lib0/set.js'
|
||||
import * as array from 'lib0/array.js'
|
||||
|
||||
/**
|
||||
* YEvent describes the changes on a YType.
|
||||
*/
|
||||
@@ -28,6 +31,10 @@ export class YEvent {
|
||||
* @type {Transaction}
|
||||
*/
|
||||
this.transaction = transaction
|
||||
/**
|
||||
* @type {Object|null}
|
||||
*/
|
||||
this._changes = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +56,8 @@ export class YEvent {
|
||||
/**
|
||||
* Check if a struct is deleted by this event.
|
||||
*
|
||||
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
|
||||
*
|
||||
* @param {AbstractStruct} struct
|
||||
* @return {boolean}
|
||||
*/
|
||||
@@ -59,12 +68,121 @@ export class YEvent {
|
||||
/**
|
||||
* Check if a struct is added by this event.
|
||||
*
|
||||
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
|
||||
*
|
||||
* @param {AbstractStruct} struct
|
||||
* @return {boolean}
|
||||
*/
|
||||
adds (struct) {
|
||||
return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {{added:Set<Item>,deleted:Set<Item>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
|
||||
*/
|
||||
get changes () {
|
||||
let changes = this._changes
|
||||
if (changes === null) {
|
||||
const target = this.target
|
||||
const added = set.create()
|
||||
const deleted = set.create()
|
||||
/**
|
||||
* @type {Array<{insert:Array<any>}|{delete:number}|{retain:number}>}
|
||||
*/
|
||||
const delta = []
|
||||
/**
|
||||
* @type {Map<string,{ action: 'add' | 'update' | 'delete', oldValue: any}>}
|
||||
*/
|
||||
const keys = new Map()
|
||||
changes = {
|
||||
added, deleted, delta, keys
|
||||
}
|
||||
const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
|
||||
if (changed.has(null)) {
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let lastOp = null
|
||||
const packOp = () => {
|
||||
if (lastOp) {
|
||||
delta.push(lastOp)
|
||||
}
|
||||
}
|
||||
for (let item = target._start; item !== null; item = item.right) {
|
||||
if (item.deleted) {
|
||||
if (this.deletes(item) && !this.adds(item)) {
|
||||
if (lastOp === null || lastOp.delete === undefined) {
|
||||
packOp()
|
||||
lastOp = { delete: 0 }
|
||||
}
|
||||
lastOp.delete += item.length
|
||||
deleted.add(item)
|
||||
} // else nop
|
||||
} else {
|
||||
if (this.adds(item)) {
|
||||
if (lastOp === null || lastOp.insert === undefined) {
|
||||
packOp()
|
||||
lastOp = { insert: [] }
|
||||
}
|
||||
lastOp.insert = lastOp.insert.concat(item.content.getContent())
|
||||
added.add(item)
|
||||
} else {
|
||||
if (lastOp === null || lastOp.retain === undefined) {
|
||||
packOp()
|
||||
lastOp = { retain: 0 }
|
||||
}
|
||||
lastOp.retain += item.length
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastOp !== null && lastOp.retain === undefined) {
|
||||
packOp()
|
||||
}
|
||||
}
|
||||
changed.forEach(key => {
|
||||
if (key !== null) {
|
||||
const item = /** @type {Item} */ (target._map.get(key))
|
||||
/**
|
||||
* @type {'delete' | 'add' | 'update'}
|
||||
*/
|
||||
let action
|
||||
let oldValue
|
||||
if (this.adds(item)) {
|
||||
let prev = item.left
|
||||
while (prev !== null && this.adds(prev)) {
|
||||
prev = prev.left
|
||||
}
|
||||
if (this.deletes(item)) {
|
||||
if (prev !== null && this.deletes(prev)) {
|
||||
action = 'delete'
|
||||
oldValue = array.last(prev.content.getContent())
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (prev !== null && this.deletes(prev)) {
|
||||
action = 'update'
|
||||
oldValue = array.last(prev.content.getContent())
|
||||
} else {
|
||||
action = 'add'
|
||||
oldValue = undefined
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.deletes(item)) {
|
||||
action = 'delete'
|
||||
oldValue = array.last(/** @type {Item} */ item.content.getContent())
|
||||
} else {
|
||||
return // nop
|
||||
}
|
||||
}
|
||||
keys.set(key, { action, oldValue })
|
||||
}
|
||||
})
|
||||
this._changes = changes
|
||||
}
|
||||
return /** @type {any} */ (changes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +211,7 @@ const getPathTo = (parent, child) => {
|
||||
} else {
|
||||
// parent is array-ish
|
||||
let i = 0
|
||||
let c = child._item.parent._start
|
||||
let c = /** @type {AbstractType<any>} */ (child._item.parent)._start
|
||||
while (c !== child._item && c !== null) {
|
||||
if (!c.deleted) {
|
||||
i++
|
||||
@@ -102,7 +220,7 @@ const getPathTo = (parent, child) => {
|
||||
}
|
||||
path.unshift(i)
|
||||
}
|
||||
child = child._item.parent
|
||||
child = /** @type {AbstractType<any>} */ (child._item.parent)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
|
||||
import {
|
||||
findIndexSS,
|
||||
GCRef,
|
||||
ItemRef,
|
||||
writeID,
|
||||
createID,
|
||||
readID,
|
||||
getState,
|
||||
createID,
|
||||
getStateVector,
|
||||
readDeleteSet,
|
||||
readAndApplyDeleteSet,
|
||||
writeDeleteSet,
|
||||
createDeleteSetFromStructStore,
|
||||
Doc, Transaction, AbstractStruct, StructStore, ID // eslint-disable-line
|
||||
transact,
|
||||
readItem,
|
||||
Doc, Transaction, GC, Item, StructStore, ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -35,7 +35,7 @@ import * as binary from 'lib0/binary.js'
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {Array<AbstractStruct>} structs All structs by `client`
|
||||
* @param {Array<GC|Item>} structs All structs by `client`
|
||||
* @param {number} client
|
||||
* @param {number} clock write structs starting with `ID(client,clock)`
|
||||
*
|
||||
@@ -49,35 +49,12 @@ const writeStructs = (encoder, structs, client, clock) => {
|
||||
writeID(encoder, createID(client, clock))
|
||||
const firstStruct = structs[startNewStructs]
|
||||
// write first struct with an offset
|
||||
firstStruct.write(encoder, clock - firstStruct.id.clock, 0)
|
||||
firstStruct.write(encoder, clock - firstStruct.id.clock)
|
||||
for (let i = startNewStructs + 1; i < structs.length; i++) {
|
||||
structs[i].write(encoder, 0, 0)
|
||||
structs[i].write(encoder, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {number} numOfStructs
|
||||
* @param {ID} nextID
|
||||
* @return {Array<GCRef|ItemRef>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
const readStructRefs = (decoder, numOfStructs, nextID) => {
|
||||
/**
|
||||
* @type {Array<GCRef|ItemRef>}
|
||||
*/
|
||||
const refs = []
|
||||
for (let i = 0; i < numOfStructs; i++) {
|
||||
const info = decoding.readUint8(decoder)
|
||||
const ref = (binary.BITS5 & info) === 0 ? new GCRef(decoder, nextID, info) : new ItemRef(decoder, nextID, info)
|
||||
nextID = createID(nextID.client, nextID.clock + ref.length)
|
||||
refs.push(ref)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {StructStore} store
|
||||
@@ -102,7 +79,9 @@ export const writeClientsStructs = (encoder, store, _sm) => {
|
||||
})
|
||||
// write # states that were updated
|
||||
encoding.writeVarUint(encoder, sm.size)
|
||||
sm.forEach((clock, client) => {
|
||||
// Write items with higher client ids first
|
||||
// This heavily improves the conflict algorithm.
|
||||
Array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
|
||||
// @ts-ignore
|
||||
writeStructs(encoder, store.clients.get(client), client, clock)
|
||||
})
|
||||
@@ -110,22 +89,32 @@ export const writeClientsStructs = (encoder, store, _sm) => {
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder The decoder object to read data from.
|
||||
* @return {Map<number,Array<GCRef|ItemRef>>}
|
||||
* @param {Map<number,Array<GC|Item>>} clientRefs
|
||||
* @param {Doc} doc
|
||||
* @return {Map<number,Array<GC|Item>>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readClientsStructRefs = decoder => {
|
||||
/**
|
||||
* @type {Map<number,Array<GCRef|ItemRef>>}
|
||||
*/
|
||||
const clientRefs = new Map()
|
||||
export const readClientsStructRefs = (decoder, clientRefs, doc) => {
|
||||
const numOfStateUpdates = decoding.readVarUint(decoder)
|
||||
for (let i = 0; i < numOfStateUpdates; i++) {
|
||||
const numberOfStructs = decoding.readVarUint(decoder)
|
||||
const nextID = readID(decoder)
|
||||
const refs = readStructRefs(decoder, numberOfStructs, nextID)
|
||||
clientRefs.set(nextID.client, refs)
|
||||
const nextIdClient = nextID.client
|
||||
let nextIdClock = nextID.clock
|
||||
/**
|
||||
* @type {Array<GC|Item>}
|
||||
*/
|
||||
const refs = []
|
||||
clientRefs.set(nextIdClient, refs)
|
||||
for (let i = 0; i < numberOfStructs; i++) {
|
||||
const info = decoding.readUint8(decoder)
|
||||
const id = createID(nextIdClient, nextIdClock)
|
||||
const struct = (binary.BITS5 & info) === 0 ? new GC(id, decoding.readVarUint(decoder)) : readItem(decoder, id, info, doc)
|
||||
refs.push(struct)
|
||||
nextIdClock += struct.length
|
||||
}
|
||||
}
|
||||
return clientRefs
|
||||
}
|
||||
@@ -169,17 +158,19 @@ const resumeStructIntegration = (transaction, store) => {
|
||||
}
|
||||
}
|
||||
const ref = stack[stack.length - 1]
|
||||
const m = ref._missing
|
||||
const client = ref.id.client
|
||||
const refID = ref.id
|
||||
const client = refID.client
|
||||
const refClock = refID.clock
|
||||
const localClock = getState(store, client)
|
||||
const offset = ref.id.clock < localClock ? localClock - ref.id.clock : 0
|
||||
if (ref.id.clock + offset !== localClock) {
|
||||
const offset = refClock < localClock ? localClock - refClock : 0
|
||||
const missing = ref.getMissing(transaction, store)
|
||||
if (refClock + offset !== localClock) {
|
||||
// A previous message from this client is missing
|
||||
// check if there is a pending structRef with a smaller clock and switch them
|
||||
const structRefs = clientsStructRefs.get(client)
|
||||
if (structRefs !== undefined) {
|
||||
const r = structRefs.refs[structRefs.i]
|
||||
if (r.id.clock < ref.id.clock) {
|
||||
if (r.id.clock < refClock) {
|
||||
// put ref with smaller clock on stack instead and continue
|
||||
structRefs.refs[structRefs.i] = ref
|
||||
stack[stack.length - 1] = r
|
||||
@@ -192,27 +183,21 @@ const resumeStructIntegration = (transaction, store) => {
|
||||
// wait until missing struct is available
|
||||
return
|
||||
}
|
||||
while (m.length > 0) {
|
||||
const missing = m[m.length - 1]
|
||||
if (getState(store, missing.client) <= missing.clock) {
|
||||
const client = missing.client
|
||||
// get the struct reader that has the missing struct
|
||||
const structRefs = clientsStructRefs.get(client)
|
||||
if (structRefs === undefined) {
|
||||
// This update message causally depends on another update message.
|
||||
return
|
||||
}
|
||||
stack.push(structRefs.refs[structRefs.i++])
|
||||
if (structRefs.i === structRefs.refs.length) {
|
||||
clientsStructRefs.delete(client)
|
||||
}
|
||||
break
|
||||
if (missing) {
|
||||
const client = missing.client
|
||||
// get the struct reader that has the missing struct
|
||||
const structRefs = clientsStructRefs.get(client)
|
||||
if (structRefs === undefined) {
|
||||
// This update message causally depends on another update message.
|
||||
return
|
||||
}
|
||||
ref._missing.pop()
|
||||
}
|
||||
if (m.length === 0) {
|
||||
stack.push(structRefs.refs[structRefs.i++])
|
||||
if (structRefs.i === structRefs.refs.length) {
|
||||
clientsStructRefs.delete(client)
|
||||
}
|
||||
} else {
|
||||
if (offset < ref.length) {
|
||||
ref.toStruct(transaction, store, offset).integrate(transaction)
|
||||
ref.integrate(transaction, offset)
|
||||
}
|
||||
stack.pop()
|
||||
}
|
||||
@@ -230,7 +215,7 @@ export const tryResumePendingDeleteReaders = (transaction, store) => {
|
||||
const pendingReaders = store.pendingDeleteReaders
|
||||
store.pendingDeleteReaders = []
|
||||
for (let i = 0; i < pendingReaders.length; i++) {
|
||||
readDeleteSet(pendingReaders[i], transaction, store)
|
||||
readAndApplyDeleteSet(pendingReaders[i], transaction, store)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +230,7 @@ export const writeStructsFromTransaction = (encoder, transaction) => writeClient
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
* @param {Map<number, Array<GCRef|ItemRef>>} clientsStructsRefs
|
||||
* @param {Map<number, Array<GC|Item>>} clientsStructsRefs
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
@@ -281,7 +266,8 @@ const mergeReadStructsIntoPendingReads = (store, clientsStructsRefs) => {
|
||||
* @function
|
||||
*/
|
||||
export const readStructs = (decoder, transaction, store) => {
|
||||
const clientsStructRefs = readClientsStructRefs(decoder)
|
||||
const clientsStructRefs = new Map()
|
||||
readClientsStructRefs(decoder, clientsStructRefs, transaction.doc)
|
||||
mergeReadStructsIntoPendingReads(store, clientsStructRefs)
|
||||
resumeStructIntegration(transaction, store)
|
||||
tryResumePendingDeleteReaders(transaction, store)
|
||||
@@ -299,10 +285,10 @@ export const readStructs = (decoder, transaction, store) => {
|
||||
* @function
|
||||
*/
|
||||
export const readUpdate = (decoder, ydoc, transactionOrigin) =>
|
||||
ydoc.transact(transaction => {
|
||||
transact(ydoc, transaction => {
|
||||
readStructs(decoder, transaction, ydoc.store)
|
||||
readDeleteSet(decoder, transaction, ydoc.store)
|
||||
}, transactionOrigin)
|
||||
readAndApplyDeleteSet(decoder, transaction, ydoc.store)
|
||||
}, transactionOrigin, false)
|
||||
|
||||
/**
|
||||
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
|
||||
@@ -381,6 +367,22 @@ export const readStateVector = decoder => {
|
||||
*/
|
||||
export const decodeStateVector = decodedState => readStateVector(decoding.createDecoder(decodedState))
|
||||
|
||||
/**
|
||||
* Write State Vector to `lib0/encoding.js#Encoder`.
|
||||
*
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {Map<number,number>} sv
|
||||
* @function
|
||||
*/
|
||||
export const writeStateVector = (encoder, sv) => {
|
||||
encoding.writeVarUint(encoder, sv.size)
|
||||
sv.forEach((clock, client) => {
|
||||
encoding.writeVarUint(encoder, client)
|
||||
encoding.writeVarUint(encoder, clock)
|
||||
})
|
||||
return encoder
|
||||
}
|
||||
|
||||
/**
|
||||
* Write State Vector to `lib0/encoding.js#Encoder`.
|
||||
*
|
||||
@@ -389,16 +391,7 @@ export const decodeStateVector = decodedState => readStateVector(decoding.create
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const writeDocumentStateVector = (encoder, doc) => {
|
||||
encoding.writeVarUint(encoder, doc.store.clients.size)
|
||||
doc.store.clients.forEach((structs, client) => {
|
||||
const struct = structs[structs.length - 1]
|
||||
const id = struct.id
|
||||
encoding.writeVarUint(encoder, id.client)
|
||||
encoding.writeVarUint(encoder, id.clock + struct.length)
|
||||
})
|
||||
return encoder
|
||||
}
|
||||
export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store))
|
||||
|
||||
/**
|
||||
* Encode State as Uint8Array.
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
|
||||
import { AbstractType } from '../internals.js' // eslint-disable-line
|
||||
import { AbstractType, Item } from '../internals.js' // eslint-disable-line
|
||||
|
||||
/**
|
||||
* Check if `parent` is a parent of `child`.
|
||||
*
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {AbstractType<any>} child
|
||||
* @param {Item|null} child
|
||||
* @return {Boolean} Whether `parent` is a parent of `child`.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const isParentOf = (parent, child) => {
|
||||
while (child._item !== null) {
|
||||
if (child === parent) {
|
||||
while (child !== null) {
|
||||
if (child.parent === parent) {
|
||||
return true
|
||||
}
|
||||
child = child._item.parent
|
||||
child = /** @type {AbstractType<any>} */ (child.parent)._item
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
19
tests/consistency.tests.js
Normal file
19
tests/consistency.tests.js
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
|
||||
/**
|
||||
* Client id should be changed when an instance receives updates from another client using the same client id.
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testClientIdDuplicateChange = tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
doc1.clientID = 0
|
||||
const doc2 = new Y.Doc()
|
||||
doc2.clientID = 0
|
||||
t.assert(doc2.clientID === doc1.clientID)
|
||||
doc1.getArray('a').insert(0, [1, 2])
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
||||
t.assert(doc2.clientID !== doc1.clientID)
|
||||
}
|
||||
@@ -8,19 +8,21 @@ import {
|
||||
readContentJSON,
|
||||
readContentEmbed,
|
||||
readContentType,
|
||||
readContentFormat
|
||||
readContentFormat,
|
||||
readContentAny
|
||||
} from '../src/internals.js'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testStructReferences = tc => {
|
||||
t.assert(contentRefs.length === 8)
|
||||
t.assert(contentRefs.length === 9)
|
||||
t.assert(contentRefs[1] === readContentDeleted)
|
||||
t.assert(contentRefs[2] === readContentJSON)
|
||||
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
|
||||
t.assert(contentRefs[3] === readContentBinary)
|
||||
t.assert(contentRefs[4] === readContentString)
|
||||
t.assert(contentRefs[5] === readContentEmbed)
|
||||
t.assert(contentRefs[6] === readContentFormat)
|
||||
t.assert(contentRefs[7] === readContentType)
|
||||
t.assert(contentRefs[8] === readContentAny)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as map from './y-map.tests.js'
|
||||
import * as text from './y-text.tests.js'
|
||||
import * as xml from './y-xml.tests.js'
|
||||
import * as encoding from './encoding.tests.js'
|
||||
import * as undoredo from './undo-redo.tests.js'
|
||||
import * as consistency from './consistency.tests.js'
|
||||
|
||||
import { runTests } from 'lib0/testing.js'
|
||||
import { isBrowser, isNode } from 'lib0/environment.js'
|
||||
@@ -13,7 +15,7 @@ if (isBrowser) {
|
||||
log.createVConsole(document.body)
|
||||
}
|
||||
runTests({
|
||||
map, array, text, xml, encoding
|
||||
map, array, text, xml, consistency, encoding, undoredo
|
||||
}).then(success => {
|
||||
/* istanbul ignore next */
|
||||
if (isNode) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as prng from 'lib0/prng.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as syncProtocol from 'y-protocols/sync.js'
|
||||
import * as object from 'lib0/object.js'
|
||||
export * from '../src/internals.js'
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,7 @@ export class TestYInstance extends Doc {
|
||||
})
|
||||
this.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from TestConnector.
|
||||
*/
|
||||
@@ -62,6 +64,7 @@ export class TestYInstance extends Doc {
|
||||
this.receiving = new Map()
|
||||
this.tc.onlineConns.delete(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append yourself to the list of known Y instances in testconnector.
|
||||
* Also initiate sync with all clients.
|
||||
@@ -83,6 +86,7 @@ export class TestYInstance extends Doc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive a message from another client. This message is only appended to the list of receiving messages.
|
||||
* TestConnector decides when this client actually reads this message.
|
||||
@@ -124,6 +128,7 @@ export class TestConnector {
|
||||
*/
|
||||
this.prng = gen
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Y instance and add it to the list of connections
|
||||
* @param {number} clientID
|
||||
@@ -131,6 +136,7 @@ export class TestConnector {
|
||||
createY (clientID) {
|
||||
return new TestYInstance(this, clientID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose random connection and flush a random message from a random sender.
|
||||
*
|
||||
@@ -162,6 +168,7 @@ export class TestConnector {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} True iff this function actually flushed something
|
||||
*/
|
||||
@@ -172,16 +179,20 @@ export class TestConnector {
|
||||
}
|
||||
return didSomething
|
||||
}
|
||||
|
||||
reconnectAll () {
|
||||
this.allConns.forEach(conn => conn.connect())
|
||||
}
|
||||
|
||||
disconnectAll () {
|
||||
this.allConns.forEach(conn => conn.disconnect())
|
||||
}
|
||||
|
||||
syncAll () {
|
||||
this.reconnectAll()
|
||||
this.flushAllMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} Whether it was possible to disconnect a randon connection.
|
||||
*/
|
||||
@@ -192,6 +203,7 @@ export class TestConnector {
|
||||
prng.oneOf(this.prng, Array.from(this.onlineConns)).disconnect()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} Whether it was possible to reconnect a random connection.
|
||||
*/
|
||||
@@ -266,13 +278,16 @@ export const compare = users => {
|
||||
t.assert(u.store.pendingClientsStructRefs.size === 0)
|
||||
}
|
||||
// Test Array iterator
|
||||
t.compare(userArrayValues[0], Array.from(users[0].getArray('array').toJSON()))
|
||||
t.compare(users[0].getArray('array').toArray(), Array.from(users[0].getArray('array')))
|
||||
// Test Map iterator
|
||||
const ymapkeys = Array.from(users[0].getMap('map').keys())
|
||||
t.assert(ymapkeys.length === Object.keys(userMapValues[0]).length)
|
||||
ymapkeys.forEach(key => t.assert(object.hasProperty(userMapValues[0], key)))
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
const mapRes = {}
|
||||
for (let [k, v] of users[0].getMap('map')) {
|
||||
for (const [k, v] of users[0].getMap('map')) {
|
||||
mapRes[k] = v instanceof Y.AbstractType ? v.toJSON() : v
|
||||
}
|
||||
t.compare(userMapValues[0], mapRes)
|
||||
@@ -282,7 +297,7 @@ export const compare = users => {
|
||||
t.compare(userArrayValues[i], userArrayValues[i + 1])
|
||||
t.compare(userMapValues[i], userMapValues[i + 1])
|
||||
t.compare(userXmlValues[i], userXmlValues[i + 1])
|
||||
t.compare(userTextValues[i].map(/** @param {any} a */ a => a.insert).join('').length, users[i].getText('text').length)
|
||||
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(getStateVector(users[i].store), getStateVector(users[i + 1].store))
|
||||
compareDS(createDeleteSetFromStructStore(users[i].store), createDeleteSetFromStructStore(users[i + 1].store))
|
||||
@@ -315,6 +330,7 @@ export const compareStructStores = (ss1, ss2) => {
|
||||
s1.constructor !== s2.constructor ||
|
||||
!Y.compareIDs(s1.id, s2.id) ||
|
||||
s1.deleted !== s2.deleted ||
|
||||
// @ts-ignore
|
||||
s1.length !== s2.length
|
||||
) {
|
||||
t.fail('Structs dont match')
|
||||
@@ -375,7 +391,7 @@ export const compareDS = (ds1, ds2) => {
|
||||
*/
|
||||
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
|
||||
const gen = tc.prng
|
||||
const result = init(tc, { users: 5 }, initTestObject || (() => null))
|
||||
const result = init(tc, { users: 5 }, initTestObject)
|
||||
const { testConnector, users } = result
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (prng.int31(gen, 0, 100) <= 2) {
|
||||
|
||||
248
tests/undo-redo.tests.js
Normal file
248
tests/undo-redo.tests.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
|
||||
|
||||
import {
|
||||
UndoManager
|
||||
} from '../src/internals.js'
|
||||
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoText = tc => {
|
||||
const { testConnector, text0, text1 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(text0)
|
||||
|
||||
// items that are added & deleted in the same transaction won't be undo
|
||||
text0.insert(0, 'test')
|
||||
text0.delete(0, 4)
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
|
||||
// follow redone items
|
||||
text0.insert(0, 'a')
|
||||
undoManager.stopCapturing()
|
||||
text0.delete(0, 1)
|
||||
undoManager.stopCapturing()
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === 'a')
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
|
||||
text0.insert(0, 'abc')
|
||||
text1.insert(0, 'xyz')
|
||||
testConnector.syncAll()
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === 'xyz')
|
||||
undoManager.redo()
|
||||
t.assert(text0.toString() === 'abcxyz')
|
||||
testConnector.syncAll()
|
||||
text1.delete(0, 1)
|
||||
testConnector.syncAll()
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === 'xyz')
|
||||
undoManager.redo()
|
||||
t.assert(text0.toString() === 'bcxyz')
|
||||
// test marks
|
||||
text0.format(1, 3, { bold: true })
|
||||
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
|
||||
undoManager.undo()
|
||||
t.compare(text0.toDelta(), [{ insert: 'bcxyz' }])
|
||||
undoManager.redo()
|
||||
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoMap = tc => {
|
||||
const { testConnector, map0, map1 } = init(tc, { users: 2 })
|
||||
map0.set('a', 0)
|
||||
const undoManager = new UndoManager(map0)
|
||||
map0.set('a', 1)
|
||||
undoManager.undo()
|
||||
t.assert(map0.get('a') === 0)
|
||||
undoManager.redo()
|
||||
t.assert(map0.get('a') === 1)
|
||||
// testing sub-types and if it can restore a whole type
|
||||
const subType = new Y.Map()
|
||||
map0.set('a', subType)
|
||||
subType.set('x', 42)
|
||||
t.compare(map0.toJSON(), /** @type {any} */ ({ a: { x: 42 } }))
|
||||
undoManager.undo()
|
||||
t.assert(map0.get('a') === 1)
|
||||
undoManager.redo()
|
||||
t.compare(map0.toJSON(), /** @type {any} */ ({ a: { x: 42 } }))
|
||||
testConnector.syncAll()
|
||||
// if content is overwritten by another user, undo operations should be skipped
|
||||
map1.set('a', 44)
|
||||
testConnector.syncAll()
|
||||
undoManager.undo()
|
||||
t.assert(map0.get('a') === 44)
|
||||
undoManager.redo()
|
||||
t.assert(map0.get('a') === 44)
|
||||
|
||||
// test setting value multiple times
|
||||
map0.set('b', 'initial')
|
||||
undoManager.stopCapturing()
|
||||
map0.set('b', 'val1')
|
||||
map0.set('b', 'val2')
|
||||
undoManager.stopCapturing()
|
||||
undoManager.undo()
|
||||
t.assert(map0.get('b') === 'initial')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoArray = tc => {
|
||||
const { testConnector, array0, array1 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(array0)
|
||||
array0.insert(0, [1, 2, 3])
|
||||
array1.insert(0, [4, 5, 6])
|
||||
testConnector.syncAll()
|
||||
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toArray(), [4, 5, 6])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
|
||||
testConnector.syncAll()
|
||||
array1.delete(0, 1) // user1 deletes [1]
|
||||
testConnector.syncAll()
|
||||
undoManager.undo()
|
||||
t.compare(array0.toArray(), [4, 5, 6])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toArray(), [2, 3, 4, 5, 6])
|
||||
array0.delete(0, 5)
|
||||
// test nested structure
|
||||
const ymap = new Y.Map()
|
||||
array0.insert(0, [ymap])
|
||||
t.compare(array0.toJSON(), [{}])
|
||||
undoManager.stopCapturing()
|
||||
ymap.set('a', 1)
|
||||
t.compare(array0.toJSON(), [{ a: 1 }])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toJSON(), [{}])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toJSON(), [2, 3, 4, 5, 6])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toJSON(), [{}])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toJSON(), [{ a: 1 }])
|
||||
testConnector.syncAll()
|
||||
array1.get(0).set('b', 2)
|
||||
testConnector.syncAll()
|
||||
t.compare(array0.toJSON(), [{ a: 1, b: 2 }])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toJSON(), [{ b: 2 }])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toJSON(), [2, 3, 4, 5, 6])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toJSON(), [{ b: 2 }])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toJSON(), [{ a: 1, b: 2 }])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoXml = tc => {
|
||||
const { xml0 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(xml0)
|
||||
const child = new Y.XmlElement('p')
|
||||
xml0.insert(0, [child])
|
||||
const textchild = new Y.XmlText('content')
|
||||
child.insert(0, [textchild])
|
||||
t.assert(xml0.toString() === '<undefined><p>content</p></undefined>')
|
||||
// format textchild and revert that change
|
||||
undoManager.stopCapturing()
|
||||
textchild.format(3, 4, { bold: {} })
|
||||
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
|
||||
undoManager.undo()
|
||||
t.assert(xml0.toString() === '<undefined><p>content</p></undefined>')
|
||||
undoManager.redo()
|
||||
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
|
||||
xml0.delete(0, 1)
|
||||
t.assert(xml0.toString() === '<undefined></undefined>')
|
||||
undoManager.undo()
|
||||
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoEvents = tc => {
|
||||
const { text0 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(text0)
|
||||
let counter = 0
|
||||
let receivedMetadata = -1
|
||||
undoManager.on('stack-item-added', /** @param {any} event */ event => {
|
||||
t.assert(event.type != null)
|
||||
event.stackItem.meta.set('test', counter++)
|
||||
})
|
||||
undoManager.on('stack-item-popped', /** @param {any} event */ event => {
|
||||
t.assert(event.type != null)
|
||||
receivedMetadata = event.stackItem.meta.get('test')
|
||||
})
|
||||
text0.insert(0, 'abc')
|
||||
undoManager.undo()
|
||||
t.assert(receivedMetadata === 0)
|
||||
undoManager.redo()
|
||||
t.assert(receivedMetadata === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} 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]) })
|
||||
users[0].transact(() => {
|
||||
text0.insert(0, 'abc')
|
||||
}, 42)
|
||||
t.assert(text0.toString() === 'abc')
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testTypeScope = tc => {
|
||||
const { array0 } = init(tc, { users: 3 })
|
||||
// only track origins that are numbers
|
||||
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])
|
||||
text1.insert(0, 'abc')
|
||||
t.assert(undoManager.undoStack.length === 0)
|
||||
t.assert(undoManagerBoth.undoStack.length === 1)
|
||||
t.assert(text1.toString() === 'abc')
|
||||
undoManager.undo()
|
||||
t.assert(text1.toString() === 'abc')
|
||||
undoManagerBoth.undo()
|
||||
t.assert(text1.toString() === '')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoDeleteFilter = tc => {
|
||||
/**
|
||||
* @type {Array<Y.Map<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 map0 = new Y.Map()
|
||||
map0.set('hi', 1)
|
||||
const map1 = new Y.Map()
|
||||
array0.insert(0, [map0, map1])
|
||||
undoManager.undo()
|
||||
t.assert(array0.length === 1)
|
||||
array0.get(0)
|
||||
t.assert(Array.from(array0.get(0).keys()).length === 1)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
import * as prng from 'lib0/prng.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
@@ -191,6 +192,33 @@ export const testInsertAndDeleteEventsForTypes = tc => {
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testChangeEvent = tc => {
|
||||
const { array0, users } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let changes = null
|
||||
array0.observe(e => {
|
||||
changes = e.changes
|
||||
})
|
||||
const newArr = new Y.Array()
|
||||
array0.insert(0, [newArr, 4, 'dtrn'])
|
||||
t.assert(changes !== null && changes.added.size === 2 && changes.deleted.size === 0)
|
||||
t.compare(changes.delta, [{ insert: [newArr, 4, 'dtrn'] }])
|
||||
changes = null
|
||||
array0.delete(0, 2)
|
||||
t.assert(changes !== null && changes.added.size === 0 && changes.deleted.size === 2)
|
||||
t.compare(changes.delta, [{ delete: 2 }])
|
||||
changes = null
|
||||
array0.insert(1, [0.1])
|
||||
t.assert(changes !== null && changes.added.size === 1 && changes.deleted.size === 0)
|
||||
t.compare(changes.delta, [{ retain: 1 }, { insert: [0.1] }])
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -199,7 +227,7 @@ export const testInsertAndDeleteEventsForTypes2 = tc => {
|
||||
/**
|
||||
* @type {Array<Object<string,any>>}
|
||||
*/
|
||||
let events = []
|
||||
const events = []
|
||||
array0.observe(e => {
|
||||
events.push(e)
|
||||
})
|
||||
@@ -210,6 +238,24 @@ export const testInsertAndDeleteEventsForTypes2 = tc => {
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported here https://github.com/yjs/yjs/issues/155
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testNewChildDoesNotEmitEventInTransaction = tc => {
|
||||
const { array0, users } = init(tc, { users: 2 })
|
||||
let fired = false
|
||||
users[0].transact(() => {
|
||||
const newMap = new Y.Map()
|
||||
newMap.observe(() => {
|
||||
fired = true
|
||||
})
|
||||
array0.insert(0, [newMap])
|
||||
newMap.set('tst', 42)
|
||||
})
|
||||
t.assert(!fired, 'Event does not trigger')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -272,7 +318,7 @@ export const testIteratingArrayContainingTypes = tc => {
|
||||
arr.push([map])
|
||||
}
|
||||
let cnt = 0
|
||||
for (let item of arr) {
|
||||
for (const item of arr) {
|
||||
t.assert(item.get('value') === cnt++, 'value is correct')
|
||||
}
|
||||
y.destroy()
|
||||
@@ -317,12 +363,12 @@ const arrayTransactions = [
|
||||
var length = yarray.length
|
||||
if (length > 0) {
|
||||
var somePos = prng.int31(gen, 0, length - 1)
|
||||
var delLength = prng.int31(gen, 1, Math.min(2, length - somePos))
|
||||
var delLength = prng.int31(gen, 1, math.min(2, length - somePos))
|
||||
if (prng.bool(gen)) {
|
||||
var type = yarray.get(somePos)
|
||||
if (type.length > 0) {
|
||||
somePos = prng.int31(gen, 0, type.length - 1)
|
||||
delLength = prng.int31(gen, 0, Math.min(2, type.length - somePos))
|
||||
delLength = prng.int31(gen, 0, math.min(2, type.length - somePos))
|
||||
type.delete(somePos, delLength)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,33 @@ import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
import * as prng from 'lib0/prng.js'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testMapHavingIterableAsConstructorParamTests = tc => {
|
||||
const { map0 } = init(tc, { users: 1 })
|
||||
|
||||
const m1 = new Y.Map(Object.entries({ number: 1, string: 'hello' }))
|
||||
map0.set('m1', m1)
|
||||
t.assert(m1.get('number') === 1)
|
||||
t.assert(m1.get('string') === 'hello')
|
||||
|
||||
const m2 = new Y.Map([
|
||||
['object', { x: 1 }],
|
||||
['boolean', true]
|
||||
])
|
||||
map0.set('m2', m2)
|
||||
t.assert(m2.get('object').x === 1)
|
||||
t.assert(m2.get('boolean') === true)
|
||||
|
||||
const m3 = new Y.Map([...m1, ...m2])
|
||||
map0.set('m3', m3)
|
||||
t.assert(m3.get('number') === 1)
|
||||
t.assert(m3.get('string') === 'hello')
|
||||
t.assert(m3.get('object').x === 1)
|
||||
t.assert(m3.get('boolean') === true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -33,6 +60,7 @@ export const testBasicMapTests = tc => {
|
||||
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')
|
||||
|
||||
users[2].connect()
|
||||
testConnector.flushAllMessages()
|
||||
@@ -43,6 +71,7 @@ export const testBasicMapTests = tc => {
|
||||
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')
|
||||
|
||||
// compare disconnected user
|
||||
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
|
||||
@@ -66,7 +95,7 @@ export const testGetAndSetOfMapProperty = tc => {
|
||||
|
||||
testConnector.flushAllMessages()
|
||||
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
const u = user.getMap('map')
|
||||
t.compare(u.get('stuff'), 'stuffy')
|
||||
t.assert(u.get('undefined') === undefined, 'undefined')
|
||||
@@ -108,7 +137,7 @@ export const testGetAndSetOfMapPropertySyncs = tc => {
|
||||
map0.set('stuff', 'stuffy')
|
||||
t.compare(map0.get('stuff'), 'stuffy')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.compare(u.get('stuff'), 'stuffy')
|
||||
}
|
||||
@@ -123,13 +152,27 @@ export const testGetAndSetOfMapPropertyWithConflict = tc => {
|
||||
map0.set('stuff', 'c0')
|
||||
map1.set('stuff', 'c1')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.compare(u.get('stuff'), 'c1')
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSizeAndDeleteOfMapProperty = tc => {
|
||||
const { map0 } = init(tc, { users: 1 })
|
||||
map0.set('stuff', 'c0')
|
||||
map0.set('otherstuff', 'c1')
|
||||
t.assert(map0.size === 2, `map size is ${map0.size} expected 2`)
|
||||
map0.delete('stuff')
|
||||
t.assert(map0.size === 1, `map size after delete is ${map0.size}, expected 1`)
|
||||
map0.delete('otherstuff')
|
||||
t.assert(map0.size === 0, `map size after delete is ${map0.size}, expected 0`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -139,7 +182,7 @@ export const testGetAndSetAndDeleteOfMapProperty = tc => {
|
||||
map1.set('stuff', 'c1')
|
||||
map1.delete('stuff')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.assert(u.get('stuff') === undefined)
|
||||
}
|
||||
@@ -156,7 +199,7 @@ export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
|
||||
map1.set('stuff', 'c2')
|
||||
map2.set('stuff', 'c3')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.compare(u.get('stuff'), 'c3')
|
||||
}
|
||||
@@ -179,7 +222,7 @@ export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
|
||||
map3.set('stuff', 'c3')
|
||||
map3.delete('stuff')
|
||||
testConnector.flushAllMessages()
|
||||
for (let user of users) {
|
||||
for (const user of users) {
|
||||
var u = user.getMap('map')
|
||||
t.assert(u.get('stuff') === undefined)
|
||||
}
|
||||
@@ -292,6 +335,104 @@ export const testThrowsAddAndUpdateAndDeleteEvents = tc => {
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testChangeEvent = tc => {
|
||||
const { map0, users } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let changes = null
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let keyChange = null
|
||||
map0.observe(e => {
|
||||
changes = e.changes
|
||||
})
|
||||
map0.set('a', 1)
|
||||
keyChange = changes.keys.get('a')
|
||||
t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined)
|
||||
map0.set('a', 2)
|
||||
keyChange = changes.keys.get('a')
|
||||
t.assert(changes !== null && keyChange.action === 'update' && keyChange.oldValue === 1)
|
||||
users[0].transact(() => {
|
||||
map0.set('a', 3)
|
||||
map0.set('a', 4)
|
||||
})
|
||||
keyChange = changes.keys.get('a')
|
||||
t.assert(changes !== null && keyChange.action === 'update' && keyChange.oldValue === 2)
|
||||
users[0].transact(() => {
|
||||
map0.set('b', 1)
|
||||
map0.set('b', 2)
|
||||
})
|
||||
keyChange = changes.keys.get('b')
|
||||
t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined)
|
||||
users[0].transact(() => {
|
||||
map0.set('c', 1)
|
||||
map0.delete('c')
|
||||
})
|
||||
t.assert(changes !== null && changes.keys.size === 0)
|
||||
users[0].transact(() => {
|
||||
map0.set('d', 1)
|
||||
map0.set('d', 2)
|
||||
})
|
||||
keyChange = changes.keys.get('d')
|
||||
t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined)
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testYmapEventExceptionsShouldCompleteTransaction = tc => {
|
||||
const doc = new Y.Doc()
|
||||
const map = doc.getMap('map')
|
||||
|
||||
let updateCalled = false
|
||||
let throwingObserverCalled = false
|
||||
let throwingDeepObserverCalled = false
|
||||
doc.on('update', () => {
|
||||
updateCalled = true
|
||||
})
|
||||
|
||||
const throwingObserver = () => {
|
||||
throwingObserverCalled = true
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
const throwingDeepObserver = () => {
|
||||
throwingDeepObserverCalled = true
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
map.observe(throwingObserver)
|
||||
map.observeDeep(throwingDeepObserver)
|
||||
|
||||
t.fails(() => {
|
||||
map.set('y', '2')
|
||||
})
|
||||
|
||||
t.assert(updateCalled)
|
||||
t.assert(throwingObserverCalled)
|
||||
t.assert(throwingDeepObserverCalled)
|
||||
|
||||
// check if it works again
|
||||
updateCalled = false
|
||||
throwingObserverCalled = false
|
||||
throwingDeepObserverCalled = false
|
||||
t.fails(() => {
|
||||
map.set('z', '3')
|
||||
})
|
||||
|
||||
t.assert(updateCalled)
|
||||
t.assert(throwingObserverCalled)
|
||||
t.assert(throwingDeepObserverCalled)
|
||||
|
||||
t.assert(map.get('z') === '3')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -332,12 +473,12 @@ export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc
|
||||
*/
|
||||
const mapTransactions = [
|
||||
function set (user, gen) {
|
||||
let key = prng.oneOf(gen, ['one', 'two'])
|
||||
const key = prng.oneOf(gen, ['one', 'two'])
|
||||
var value = prng.utf16String(gen)
|
||||
user.getMap('map').set(key, value)
|
||||
},
|
||||
function setType (user, gen) {
|
||||
let key = prng.oneOf(gen, ['one', 'two'])
|
||||
const key = prng.oneOf(gen, ['one', 'two'])
|
||||
var type = prng.oneOf(gen, [new Y.Array(), new Y.Map()])
|
||||
user.getMap('map').set(key, type)
|
||||
if (type instanceof Y.Array) {
|
||||
@@ -347,7 +488,7 @@ const mapTransactions = [
|
||||
}
|
||||
},
|
||||
function _delete (user, gen) {
|
||||
let key = prng.oneOf(gen, ['one', 'two'])
|
||||
const key = prng.oneOf(gen, ['one', 'two'])
|
||||
user.getMap('map').delete(key)
|
||||
}
|
||||
]
|
||||
@@ -356,7 +497,7 @@ const mapTransactions = [
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests10 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 10)
|
||||
applyRandomTests(tc, mapTransactions, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { init, compare } from './testHelper.js'
|
||||
|
||||
import * as Y from './testHelper.js'
|
||||
import * as t from 'lib0/testing.js'
|
||||
import * as prng from 'lib0/prng.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
|
||||
const { init, compare } = Y
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
@@ -81,9 +84,316 @@ export const testBasicFormat = tc => {
|
||||
export const testGetDeltaWithEmbeds = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.applyDelta([{
|
||||
insert: {linebreak: 's'}
|
||||
insert: { linebreak: 's' }
|
||||
}])
|
||||
t.compare(text0.toDelta(), [{
|
||||
insert: {linebreak: 's'}
|
||||
insert: { linebreak: 's' }
|
||||
}])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSnapshot = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
const doc0 = /** @type {Y.Doc} */ (text0.doc)
|
||||
doc0.gc = false
|
||||
text0.applyDelta([{
|
||||
insert: 'abcd'
|
||||
}])
|
||||
const snapshot1 = Y.snapshot(doc0)
|
||||
text0.applyDelta([{
|
||||
retain: 1
|
||||
}, {
|
||||
insert: 'x'
|
||||
}, {
|
||||
delete: 1
|
||||
}])
|
||||
const snapshot2 = Y.snapshot(doc0)
|
||||
text0.applyDelta([{
|
||||
retain: 2
|
||||
}, {
|
||||
delete: 3
|
||||
}, {
|
||||
insert: 'x'
|
||||
}, {
|
||||
delete: 1
|
||||
}])
|
||||
const state1 = text0.toDelta(snapshot1)
|
||||
t.compare(state1, [{ insert: 'abcd' }])
|
||||
const state2 = text0.toDelta(snapshot2)
|
||||
t.compare(state2, [{ insert: 'axcd' }])
|
||||
const state2Diff = text0.toDelta(snapshot2, snapshot1)
|
||||
// @ts-ignore Remove userid info
|
||||
state2Diff.forEach(v => {
|
||||
if (v.attributes && v.attributes.ychange) {
|
||||
delete v.attributes.ychange.user
|
||||
}
|
||||
})
|
||||
t.compare(state2Diff, [{ insert: 'a' }, { insert: 'x', attributes: { ychange: { type: 'added' } } }, { insert: 'b', attributes: { ychange: { type: 'removed' } } }, { insert: 'cd' }])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSnapshotDeleteAfter = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
const doc0 = /** @type {Y.Doc} */ (text0.doc)
|
||||
doc0.gc = false
|
||||
text0.applyDelta([{
|
||||
insert: 'abcd'
|
||||
}])
|
||||
const snapshot1 = Y.snapshot(doc0)
|
||||
text0.applyDelta([{
|
||||
retain: 4
|
||||
}, {
|
||||
insert: 'e'
|
||||
}])
|
||||
const state1 = text0.toDelta(snapshot1)
|
||||
t.compare(state1, [{ insert: 'abcd' }])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToJson = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'abc', { bold: true })
|
||||
t.assert(text0.toJSON() === 'abc', 'toJSON returns the unformatted text')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToDeltaEmbedAttributes = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'ab', { bold: true })
|
||||
text0.insertEmbed(1, { image: 'imageSrc.png' }, { width: 100 })
|
||||
const delta0 = text0.toDelta()
|
||||
t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' }, attributes: { width: 100 } }, { insert: 'b', attributes: { bold: true } }])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testToDeltaEmbedNoAttributes = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'ab', { bold: true })
|
||||
text0.insertEmbed(1, { image: 'imageSrc.png' })
|
||||
const delta0 = text0.toDelta()
|
||||
t.compare(delta0, [{ insert: 'a', attributes: { bold: true } }, { insert: { image: 'imageSrc.png' } }, { insert: 'b', attributes: { bold: true } }], 'toDelta does not set attributes key when no attributes are present')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testFormattingRemoved = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, 'ab', { bold: true })
|
||||
text0.delete(0, 2)
|
||||
t.assert(Y.getTypeChildren(text0).length === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testFormattingRemovedInMidText = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.insert(0, '1234')
|
||||
text0.insert(2, 'ab', { bold: true })
|
||||
text0.delete(2, 2)
|
||||
t.assert(Y.getTypeChildren(text0).length === 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testLargeFragmentedDocument = tc => {
|
||||
const itemsToInsert = 1000000
|
||||
let update = /** @type {any} */ (null)
|
||||
;(() => {
|
||||
const doc1 = new Y.Doc()
|
||||
const text0 = doc1.getText('txt')
|
||||
t.measureTime(`time to insert ${itemsToInsert}`, () => {
|
||||
doc1.transact(() => {
|
||||
for (let i = 0; i < itemsToInsert; i++) {
|
||||
text0.insert(0, '0')
|
||||
}
|
||||
})
|
||||
})
|
||||
t.measureTime('time to encode', () => {
|
||||
update = Y.encodeStateAsUpdate(doc1)
|
||||
})
|
||||
})()
|
||||
;(() => {
|
||||
const doc2 = new Y.Doc()
|
||||
t.measureTime('time to apply', () => {
|
||||
Y.applyUpdate(doc2, update)
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
// RANDOM TESTS
|
||||
|
||||
let charCounter = 0
|
||||
|
||||
const marks = [
|
||||
{ bold: true },
|
||||
{ italic: true },
|
||||
{ italic: true, color: '#888' }
|
||||
]
|
||||
|
||||
const marksChoices = [
|
||||
undefined,
|
||||
...marks
|
||||
]
|
||||
|
||||
/**
|
||||
* @type Array<function(any,prng.PRNG):void>
|
||||
*/
|
||||
const qChanges = [
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert text
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.toString().length)
|
||||
const attrs = prng.oneOf(gen, marksChoices)
|
||||
const text = charCounter++ + prng.word(gen)
|
||||
ytext.insert(insertPos, text, attrs)
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert embed
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.toString().length)
|
||||
ytext.insertEmbed(insertPos, { image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' })
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // delete text
|
||||
const ytext = y.getText('text')
|
||||
const contentLen = ytext.toString().length
|
||||
const insertPos = prng.int32(gen, 0, contentLen)
|
||||
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
|
||||
ytext.delete(insertPos, overwrite)
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // format text
|
||||
const ytext = y.getText('text')
|
||||
const contentLen = ytext.toString().length
|
||||
const insertPos = prng.int32(gen, 0, contentLen)
|
||||
const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2)
|
||||
const format = prng.oneOf(gen, marks)
|
||||
ytext.format(insertPos, overwrite, format)
|
||||
},
|
||||
/**
|
||||
* @param {Y.Doc} y
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
(y, gen) => { // insert codeblock
|
||||
const ytext = y.getText('text')
|
||||
const insertPos = prng.int32(gen, 0, ytext.toString().length)
|
||||
const text = charCounter++ + prng.word(gen)
|
||||
const ops = []
|
||||
if (insertPos > 0) {
|
||||
ops.push({ retain: insertPos })
|
||||
}
|
||||
ops.push({ insert: text }, { insert: '\n', format: { 'code-block': true } })
|
||||
ytext.applyDelta(ops)
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {any} result
|
||||
*/
|
||||
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()
|
||||
t.compare(p1, p2)
|
||||
}
|
||||
// Uncomment this to find formatting-cleanup issues
|
||||
// const cleanups = Y.cleanupYTextFormatting(result.users[0].getText('text'))
|
||||
// t.assert(cleanups === 0)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges1 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 1))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges2 = tc => {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges2Repeat = tc => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2))
|
||||
const cleanups = Y.cleanupYTextFormatting(users[0].getText('text'))
|
||||
t.assert(cleanups === 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges3 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 3))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges30 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 30))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges40 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 40))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges70 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 70))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges100 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 100))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGenerateQuillChanges300 = tc => {
|
||||
checkResult(Y.applyRandomTests(tc, qChanges, 300))
|
||||
}
|
||||
|
||||
@@ -60,13 +60,13 @@ export const testEvents = tc => {
|
||||
*/
|
||||
export const testTreewalker = tc => {
|
||||
const { users, xml0 } = init(tc, { users: 3 })
|
||||
let paragraph1 = new Y.XmlElement('p')
|
||||
let paragraph2 = new Y.XmlElement('p')
|
||||
let text1 = new Y.XmlText('init')
|
||||
let text2 = new Y.XmlText('text')
|
||||
const paragraph1 = new Y.XmlElement('p')
|
||||
const paragraph2 = new Y.XmlElement('p')
|
||||
const text1 = new Y.XmlText('init')
|
||||
const text2 = new Y.XmlText('text')
|
||||
paragraph1.insert(0, [text1, text2])
|
||||
xml0.insert(0, [paragraph1, paragraph2, new Y.XmlElement('img')])
|
||||
let allParagraphs = xml0.querySelectorAll('p')
|
||||
const allParagraphs = xml0.querySelectorAll('p')
|
||||
t.assert(allParagraphs.length === 2, 'found exactly two paragraphs')
|
||||
t.assert(allParagraphs[0] === paragraph1, 'querySelectorAll found paragraph1')
|
||||
t.assert(allParagraphs[1] === paragraph2, 'querySelectorAll found paragraph2')
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
"allowJs": true, /* Allow javascript files to be compiled. */
|
||||
"checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
"declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
// "outDir": "./build", /* Redirect output structure to the directory. */
|
||||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "outFile": "./dist/yjs.js", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./dist", /* Redirect output structure to the directory. */
|
||||
"rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
"noEmit": true, /* Do not emit outputs. */
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
@@ -22,6 +22,7 @@
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"emitDeclarationOnly": true,
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
@@ -56,9 +57,8 @@
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
"maxNodeModuleJsDepth": 5,
|
||||
// "maxNodeModuleJsDepth": 0,
|
||||
// "types": ["./src/utils/typedefs.js"]
|
||||
},
|
||||
"include": ["./src/**/*", "./tests/**/*"],
|
||||
"exclude": ["../lib0/**/*", "node_modules/**/*", "dist", "dist/**/*.js"]
|
||||
"include": ["./src/**/*.js", "./tests/**/*.js"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user