Compare commits
27 Commits
v13-refact
...
v13.0.0-84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6593412a2 | ||
|
|
d31bf36531 | ||
|
|
a485f550db | ||
|
|
0610b16227 | ||
|
|
72e470c5f0 | ||
|
|
4d12a02e2f | ||
|
|
4a7d6f0a2d | ||
|
|
c80f446b5f | ||
|
|
81a529d8dc | ||
|
|
4f0ab78914 | ||
|
|
8c36f67f0b | ||
|
|
77687d94e6 | ||
|
|
4644511303 | ||
|
|
20005eecdb | ||
|
|
c9dda245bf | ||
|
|
1417470156 | ||
|
|
584e5dfd40 | ||
|
|
805acbb9f5 | ||
|
|
32c4c09072 | ||
|
|
8c5a06bbf8 | ||
|
|
a336cc167c | ||
|
|
21d86cd2be | ||
|
|
1d0f9faa91 | ||
|
|
45237571b7 | ||
|
|
bb6f6cd141 | ||
|
|
729c1f16b8 | ||
|
|
b6059704aa |
@@ -6,6 +6,8 @@ text, richtext, json, or XML. It is fairly easy to get started, as Yjs hides
|
||||
most of the complexity of concurrent editing. For additional information, demos,
|
||||
and tutorials visit [y-js.org](http://y-js.org/).
|
||||
|
||||
:warning: Checkout the [v13 docs](./README.v13.md) for the upcoming release :warning:
|
||||
|
||||
### Extensions
|
||||
Yjs only knows how to resolve conflicts on shared data. You have to choose a ..
|
||||
* *Connector* - a communication protocol that propagates changes to the clients
|
||||
@@ -294,12 +296,5 @@ DEBUG_COLORS=0 DEBUG=y* node app.js > log
|
||||
localStorage.debug = 'y*'
|
||||
```
|
||||
|
||||
## Contribution
|
||||
I created this framework during my bachelor thesis at the chair of computer
|
||||
science 5 [(i5)](http://dbis.rwth-aachen.de/cms), RWTH University. Since
|
||||
December 2014 I'm working on Yjs as a part of my student worker job at the i5.
|
||||
|
||||
## License
|
||||
Yjs is licensed under the [MIT License](./LICENSE).
|
||||
|
||||
<yjs@dbis.rwth-aachen.de>
|
||||
|
||||
725
README.v13.md
725
README.v13.md
@@ -1,15 +1,42 @@
|
||||
# 
|
||||
> The shared editing library
|
||||
> A CRDT framework with a powerful abstraction of shared data
|
||||
|
||||
Yjs is a library for automatic conflict resolution on shared state. It implements an operation-based CRDT and exposes its internal CRDT model 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 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**, **shared cursors**, and encodes update messages using **binary protocol encoding**.
|
||||
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://yjs.website/tutorial-prosemirror.html](https://yjs.website/tutorial-prosemirror.html)
|
||||
* API Docs: [https://yjs.website/](https://yjs.website/)
|
||||
* 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)
|
||||
|
||||
### Supported Editors:
|
||||
# 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 |
|
||||
|---|:-:|---|---|
|
||||
@@ -19,352 +46,512 @@ Yjs is **network agnostic** (p2p!), supports many existing **rich text editors**
|
||||
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/y-js/y-ace) | [link]() |
|
||||
| [Monaco](https://microsoft.github.io/monaco-editor/) | | [y-monaco](http://github.com/y-js/y-monaco) | [link]() |
|
||||
|
||||
|
||||
### 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 a good off-the-shelf solution
|
||||
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.
|
||||
|
||||
* [y-websockets](http://github.com/y-js/y-websockets)
|
||||
* [y-webrtc](http://github.com/y-js/y-webrtc)
|
||||
* [y-dat](http://github.com/y-js/y-dat)
|
||||
|
||||
|
||||
### Shared Types
|
||||
|
||||
# Table of Contents
|
||||
|
||||
* [Getting Started](#Getting-Started)
|
||||
* [Tutorial](#Short-Tutorial)
|
||||
* [Providers](#Providers)
|
||||
* [Websocket](#Websocket)
|
||||
* [Ydb](#Ydb)
|
||||
* [Create a Custom Provider](#Create-a-Custom-Provider)
|
||||
* [Shared Types](#Shared-Types)
|
||||
* [YArray](#Yarray)
|
||||
* [YMap](#YMap)
|
||||
* [YText](#YText)
|
||||
* [YXmlFragment and YXmlElement](#YXmlFragment-and-YXmlElement)
|
||||
* [Create a Custom Type](#Create-a-Custom-Type)
|
||||
* [Bindings](#Bindings)
|
||||
* [PromeMirror](#ProseMirror)
|
||||
* [Quill](#Quill)
|
||||
* [CodeMirror](#CodeMirror)
|
||||
* [Ace](#Ace)
|
||||
* [Monaco](#Monace)
|
||||
* [DOM](#DOM)
|
||||
* [Textarea](#Textarea)
|
||||
* [Create a Custom Binding](#Create-a-Custom-Binding)
|
||||
* [Transaction](#Transaction)
|
||||
* [Offline Editing](#Offline-Editing)
|
||||
* [Awareness](#Awareness)
|
||||
* [Working with Yjs](#Working-with-Yjs)
|
||||
* [Typescript Declarations](#Typescript-Declarations)
|
||||
* [Binary Protocols](#Binary-Protocols)
|
||||
* [Sync Protocol](#Sync-Protocols)
|
||||
* [Awareness Protocol](#Awareness-Protocols)
|
||||
* [Auth Protocol](#Auth-Protocol)
|
||||
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
|
||||
* [Evaluation](#Evaluation)
|
||||
* [Existing shared editing libraries](#Exisisting-Javascript-Products)
|
||||
* [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)
|
||||
<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
|
||||
|
||||
Yjs does not hava any dependencies. Install this package with your favorite package manager, or just copy the files into your project.
|
||||
Install Yjs and a provider with your favorite package manager.
|
||||
|
||||
```sh
|
||||
npm i yjs
|
||||
npm i yjs@13.0.0-82 y-websocket@1.0.0-3 y-textarea
|
||||
```
|
||||
|
||||
##### Quickstart
|
||||
|
||||
Yjs itself only knows how to do conflict resolution. You need to choose a provider, that handles how document updates are distributed over the network.
|
||||
|
||||
We will start by running a websocket server (part of the [websocket provider](#Websocket-Provider)):
|
||||
**Start the y-websocket server**
|
||||
|
||||
```sh
|
||||
PORT=1234 node ./node_modules/yjs/provider/websocket/server.js
|
||||
PORT=1234 node ./node_modules/y-websocket/bin/server.js
|
||||
```
|
||||
|
||||
The following client-side code connects to the websocket server and opens a shared document.
|
||||
**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 'yjs/provider/websocket.js'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { TextareaBinding } from 'y-textarea'
|
||||
|
||||
const provider = new WebsocketProvider('http://localhost:1234')
|
||||
const sharedDocument = provider.get('my-favourites')
|
||||
const doc = Y.Doc()
|
||||
const provider = new WebsocketProvider('http://localhost:1234', 'roomname')
|
||||
// sync all document updates through the websocket connection
|
||||
provider.sync('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'))
|
||||
```
|
||||
|
||||
All content created in a shared document is shared among all peers that request the same document. Now we define types on the shared document:
|
||||
**Example: Observe types**
|
||||
|
||||
```js
|
||||
sharedDocument.define('movie-ratings', Y.Map)
|
||||
sharedDocument.define('favourite-food', Y.Array)
|
||||
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"
|
||||
```
|
||||
|
||||
All clients that define `'movie-ratings'` as `Y.Map` on the shared document named `'my-favourites'` have access to the same shared type. Example:
|
||||
**Example: Nest types**
|
||||
|
||||
**Client 1:**
|
||||
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
|
||||
sharedDocument.define('movie-ratings', Y.Map)
|
||||
sharedDocument.define('favourite-food', Y.Array)
|
||||
|
||||
const movies = sharedDocument.get('movie-ratings')
|
||||
const food = sharedDocument.get('fovourite-food')
|
||||
|
||||
movies.set('deadpool', 10)
|
||||
food.insert(0, ['burger'])
|
||||
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
|
||||
```
|
||||
|
||||
**Client 2:**
|
||||
|
||||
```js
|
||||
sharedDocument.define('movie-ratings', Y.Map)
|
||||
sharedDocument.define('favourite-food', Y.Map) // <- note that this definition differs from client1
|
||||
|
||||
const movies = sharedDocument.get('movie-ratings')
|
||||
const food = sharedDocument.get('fovourite-food')
|
||||
|
||||
movies.set('yt rewind', -10)
|
||||
food.set('pancake', 10)
|
||||
|
||||
// after some time, when client1 and client2 synced, the movie list will be merged:
|
||||
movies.toJSON() // => { 'deadpool': 10, 'yt rewind': -10 }
|
||||
|
||||
// But since client1 and client2 defined the types differently,
|
||||
// they do not have access to each others food list.
|
||||
food.toJSON() // => { pancake: 10 }
|
||||
```
|
||||
|
||||
Now you understand how types are defined on a shared document. Next you can jump to one of the [tutorials on our website](https://yjs.website/tutorial-prosemirror.html) or continue reading about [Providers](#Providers), [Shared Types](#Shared-Types), and [Bindings](#Bindings).
|
||||
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
|
||||
|
||||
## Providers
|
||||
|
||||
In Yjs, a provider handles the communication channel to *authenticate*, *authorize*, and *exchange document updates*. Yjs ships with some existing providers.
|
||||
|
||||
### Websocket Provider
|
||||
|
||||
The websocket provider implements a classical client server model. Clients connect to a single endpoint over websocket. The server distributes awareness information and document updates among clients.
|
||||
|
||||
The Websocket Provider is a solid choice if you want a central source that handles authentication and authorization. Websockets also send header information and cookies, so you can use existing authentication mechanisms with this server. I recommend that you slightly adapt the server in `./provider/websocket/server.js` to your needs.
|
||||
|
||||
* Supports cross-tab communication. When you open the same document in the same browser, changes on the document are exchanged via cross-tab communication ([Broadcast Channel](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) and [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) as fallback).
|
||||
* Supports exange of awareness information (e.g. cursors)
|
||||
|
||||
##### Start a Websocket Server:
|
||||
|
||||
```sh
|
||||
PORT=1234 node ./node_modules/yjs/provider/websocket/server.js
|
||||
```
|
||||
|
||||
**Websocket Server with Persistence**
|
||||
|
||||
Persist document updates in a LevelDB database.
|
||||
|
||||
See [LevelDB Persistence](#LevelDB Persistence) for more info.
|
||||
|
||||
```sh
|
||||
PORT=1234 YPERSISTENCE=./dbDir node ./node_modules/yjs/provider/websocket/server.js
|
||||
```
|
||||
|
||||
##### Client Code:
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
|
||||
const provider = new WebsocketProvider('http://localhost:1234')
|
||||
|
||||
// open a websocket connection to http://localhost:1234/my-document-name
|
||||
const sharedDocument = provider.get('my-document-name')
|
||||
|
||||
sharedDocument.on('status', event => {
|
||||
console.log(event.status) // logs "connected" or "disconnected"
|
||||
})
|
||||
```
|
||||
|
||||
#### Scaling
|
||||
### Shared Types
|
||||
|
||||
These are mere suggestions how you could scale your server environment.
|
||||
<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|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|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|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|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|string|number|Uint8Array|Y.Type</code></b>
|
||||
<dd></dd>
|
||||
<b><code>set(key:string, value:object|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|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>
|
||||
|
||||
**Option 1:** Websocket servers communicate with each other via a PubSub server. A room is represented by a PubSub channel. The downside of this approach is that the same shared document may be handled by many servers. But the upside is that this approach is fault tolerant, does not have a single point of failure, and is perfectly fit for route balancing.
|
||||
<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>
|
||||
|
||||
**Option 2:** Sharding with *consistent hashing*. Each document is handled by a unique server. This patterns requires an entity, like etcd, that performs regular health checks and manages servers. Based on the list of available servers (which is managed by etcd) a proxy calculates which server is responsible for each requested document. The disadvantage of this approach is that it is that load distribution may not be fair. Still, this approach may be the preferred solution if you want to store the shared document in a database - e.g. for indexing.
|
||||
<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>
|
||||
|
||||
### Ydb Provider
|
||||
<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>
|
||||
|
||||
TODO
|
||||
|
||||
### Create Custom Provider
|
||||
|
||||
A provider is only a concept. I encourage you to implement the same provider interface found above. This makes it easy to exchange communication protocols.
|
||||
|
||||
Since providers handle the communication channel, they will necessarily interact with the [binary protocols](#Binary-Protocols). I suggest that you build upon the existing protocols. But you may also implement a custom communication protocol.
|
||||
|
||||
Read section [Sync Protocol](#Sync-Protocol) to learn how syncing works.
|
||||
|
||||
## Shared Types
|
||||
|
||||
A shared type is just a normal data type like [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) or [Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array). But a shared type may also be modified by a remote client. Conflicts are automatically resolved by the rules described in this section - but please note that this is only a rough overview of how conflict resolution works. Please read the [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm) section for an in-depth description of the conflict resolution approach.
|
||||
|
||||
As explained in [Tutorial](#Tutorial), a shared type is shared among all peers when they are defined with the same name on the same shared document. I.e.
|
||||
### Y.Doc
|
||||
|
||||
```js
|
||||
sharedDocument.define('my-array', Y.Array)
|
||||
|
||||
const myArray = sharedDocument.get('my-array')
|
||||
const doc = new Y.Doc()
|
||||
```
|
||||
|
||||
You may define a shared types several times, as long as you don't change the type definition.
|
||||
<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
|
||||
sharedDocument.define('my-array', Y.Array)
|
||||
const doc1 = new Y.Doc()
|
||||
const doc2 = new Y.Doc()
|
||||
|
||||
const myArray = sharedDocument.get('my-array')
|
||||
|
||||
const alsoMyArray = sharedDocument.define('my-array', Y.Array)
|
||||
|
||||
console.log(myArray === alsoMyArray) // => true
|
||||
```
|
||||
|
||||
All shared types have an `type.observe(event => ..)` method that allows you to observe any changes. You may also observe all changes on a type and any of its children with the `type.observeDeep(events => ..)` method. Here, `events` is the [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) of events that were fired on type, or any of its children.
|
||||
|
||||
All Events inherit from [YEvent](https://yjs.website/module-utils.YEvent.html).
|
||||
|
||||
### YMap
|
||||
> Complete API docs: [https://yjs.website/module-types.ymap](https://yjs.website/module-types.ymap)
|
||||
|
||||
The YMap type is very similar to the JavaScript [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map).
|
||||
|
||||
YMap fires [YMapEvents](https://yjs.website/module-types.YMapEvent.html).
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
|
||||
const ymap = new Y.Map()
|
||||
|
||||
ymap.observe(event => {
|
||||
console.log('ymap keys changed:', event.keysChanged, event.remote)
|
||||
doc1.on('update', update => {
|
||||
Y.applyUpdate(doc2, update)
|
||||
})
|
||||
|
||||
ymap.set('key', 'value') // => ymap keys changed: Set{ 'key' } false
|
||||
ymap.delete('key') // => ymap keys changed: Set{ 'key' }
|
||||
|
||||
const ymap2 = new YMap()
|
||||
ymap2.set(1, 'two')
|
||||
|
||||
ymap.set('type', ymap2) // => ymap keys changed: Set{ 'type' }
|
||||
```
|
||||
|
||||
##### Concurrent YMap changes
|
||||
|
||||
* Concurrent edits on different keys do not affect each other. E.g. if client1 does `ymap.set('a', 1)` and client2 does `ymap.set('b', 2)`, both clients will end up with `YMap{ a: 1, b: 2 }`
|
||||
* If client1 and client2 `set` the same property at the same time, the edit from the client with the smaller userID will prevail (`sharedDocument.userID`)
|
||||
* If client1 sets a property `ymap.set('a', 1)` and client2 deletes a property `ymap.delete('a')`, the set operation always prevails.
|
||||
|
||||
### YArray
|
||||
> Complete API docs: [https://yjs.website/module-types.yarray](https://yjs.website/module-types.yarray)
|
||||
|
||||
YArray fires [YArrayEvents](https://yjs.website/module-types.YMapEvent.html).
|
||||
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
|
||||
const yarray = new Y.Array()
|
||||
|
||||
yarray.observe(event => {
|
||||
console.log('yarray changed:', event.addedElements, event.removedElements, event.remote)
|
||||
doc2.on('update', update => {
|
||||
Y.applyUpdate(doc1, update)
|
||||
})
|
||||
|
||||
// insert two elements at position 0
|
||||
yarray.insert(0, ['a', 1]) // => yarray changed: Set{Item{'a'}, Item{1}}, Set{}, false
|
||||
console.log(yarray.toArray()) // => ['a', 1]
|
||||
yarray.delete(1, 1) // yarray changed: Set{}, Set{Item{1}}, false
|
||||
|
||||
yarray.insert(1, new Y.Map()) // => yarray changed: Set{YMap{}}, Set{}, false
|
||||
|
||||
// The difference between .toArray and .toJSON:
|
||||
console.log(yarray.toArray()) // => ['a', YMap{}]
|
||||
console.log(yarray.toJSON()) // => ['a', {}]
|
||||
// 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?'
|
||||
```
|
||||
|
||||
As you can see from the above example, primitive data is wrapped into an Item. This makes it possible to find the exact location of the change.
|
||||
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.
|
||||
|
||||
##### Concurrent YArray changes
|
||||
**Example: Sync two clients by exchanging the complete document structure**
|
||||
|
||||
* YArray internally represents the data as a doubly linked list. The Array `['a', YMap{}, 1]` is internally represented as `Item{'a'} <-> YMap{} <-> Item{1}`. Accordingly, the insert operation `yarray.insert(1, ['b'])` is internally transformed to `insert Item{'b'} between Item{'a'} and YMap{}`.
|
||||
* When an Item is deleted, it is only marked as deleted. Only its content is garbage collected and freed from memory.
|
||||
* Therefore, the remote operation `insert x between a and b` can still be fulfilled when item `a` or item `b` are deleted.
|
||||
* In case that two clients insert content between the same items (a concurrent insertion), the order of the insertions is decided based on the `sharedDocument.userID`.
|
||||
```js
|
||||
const state1 = Y.encodeStateAsUpdate(ydoc1)
|
||||
const state2 = Y.encodeStateAsUpdate(ydoc2)
|
||||
Y.applyUpdate(ydoc1, state2)
|
||||
Y.applyUpdate(ydoc2, state1)
|
||||
```
|
||||
|
||||
### YText
|
||||
> Complete API docs: [https://yjs.website/module-types.ytext](https://yjs.website/module-types.ytext)
|
||||
**Example: Sync two clients by computing the differences**
|
||||
|
||||
A YText is basically a [YArray](#YArray) that is optimized for text content.
|
||||
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.
|
||||
|
||||
### YXmlFragment and YXmlElement
|
||||
> Complete API docs: [https://yjs.website/module-types.yxmlfragment](https://yjs.website/module-types.yxmlfragment) and [https://yjs.website/module-types.yxmlelement](https://yjs.website/module-types.yxmlelement)
|
||||
```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)
|
||||
```
|
||||
|
||||
### Custom Types
|
||||
<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>
|
||||
|
||||
## Bindings
|
||||
### Relative Positions
|
||||
> This API is not stable yet
|
||||
|
||||
## Transaction
|
||||
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
|
||||
```
|
||||
|
||||
## Binary Protocols
|
||||
**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
|
||||
```
|
||||
|
||||
### Sync Protocol
|
||||
<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>
|
||||
|
||||
Sync steps
|
||||
|
||||
### Awareness Protocol
|
||||
|
||||
### Auth Protocol
|
||||
|
||||
## Offline Editing
|
||||
|
||||
It is trivial with Yjs to persist the local state to indexeddb, so it is always available when working offline. But there are two non-trivial questions that need to answered when implementing a professional offline editing app:
|
||||
|
||||
1. How does a client sync down all rooms that were modified while offline?
|
||||
2. How does a client sync up all rooms that were modified while offline?
|
||||
|
||||
Assuming 5000 documents are stored on each client for offline usage. How do we sync up/down each of those documents after a client comes online? It would be inefficient to sync each of those rooms separately. The only provider that currently supports syncing many rooms efficiently is Ydb, because its database layer is optimized to sync many rooms with each client.
|
||||
|
||||
If you do not care about 1. and 2. you can use `/persistences/indexeddb.js` to mirror the local state to indexeddb.
|
||||
|
||||
## Working with Yjs
|
||||
## Miscellaneous
|
||||
|
||||
### Typescript Declarations
|
||||
|
||||
Until [this](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, the only way to get type declarations is by adding Yjs to the list of checked files:
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
## CRDT Algorithm
|
||||
## 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). Some files also contain an additional copyright notice that allows you to copy and modify the code without shipping the copyright notice (e.g. `./provider/websocket/WebsocketProvider.js` and `./provider/websocket/server.js`)
|
||||
Yjs and all related projects are [**MIT licensed**](./LICENSE).
|
||||
|
||||
Yjs is based on the research I did as a student at the RWTH i5. I am working on Yjs in my spare time. Please help me by donating or hiring me for consulting, so I can continue to work on this project.
|
||||
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.
|
||||
|
||||
kevin.jahns@protonmail.com
|
||||
Fund this project by donating on [Patreon](https://www.patreon.com/dmonad) or hiring [me](https://github.com/dmonad) for professional support.
|
||||
|
||||
@@ -154,6 +154,6 @@ class NoteHistoryPlugin {
|
||||
const encoder = encoding.createEncoder()
|
||||
historyProtocol.writeHistorySnapshot(encoder, y, updatedUserMap)
|
||||
encoding.writeUint32(encoder, Math.floor(Date.now() / 1000))
|
||||
history.push([encoding.toBuffer(encoder)])
|
||||
history.push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +76,17 @@
|
||||
img[ychange_state='removed'] {
|
||||
padding: 2px;
|
||||
}
|
||||
.y-connect-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YXmlFragment type to a <a href="http://prosemirror.net">Prosemirror</a> editor.</p>
|
||||
<p>The content of this editor is shared with every client who visits this domain.</p>
|
||||
<button type="button" class="y-connect-btn">Disconnect</button>
|
||||
<p>This example shows how to bind a YXmlFragment to a <a href="http://prosemirror.net">Prosemirror</a> editor using <a href="https://github.com/y-js/y-prosemirror">y-prosemirror</a>.</p>
|
||||
<p>The content of this editor is shared with every client that visits this domain.</p>
|
||||
<div class="code-html">
|
||||
|
||||
<div id="editor" style="margin-bottom: 23px"></div>
|
||||
|
||||
@@ -12,8 +12,8 @@ import { exampleSetup } from 'prosemirror-example-setup'
|
||||
// import { noteHistoryPlugin } from './prosemirror-history.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('prosemirror', { gc: false })
|
||||
const type = ydocument.define('prosemirror', Y.XmlFragment)
|
||||
const ydocument = provider.get('prosemirror' /*, { gc: false } */)
|
||||
const type = ydocument.get('prosemirror', Y.XmlFragment)
|
||||
|
||||
const prosemirrorView = new EditorView(document.querySelector('#editor'), {
|
||||
state: EditorState.create({
|
||||
@@ -22,4 +22,15 @@ const prosemirrorView = new EditorView(document.querySelector('#editor'), {
|
||||
})
|
||||
})
|
||||
|
||||
const connectBtn = document.querySelector('.y-connect-btn')
|
||||
connectBtn.addEventListener('click', () => {
|
||||
if (ydocument.wsconnected) {
|
||||
ydocument.disconnect()
|
||||
connectBtn.textContent = 'Connect'
|
||||
} else {
|
||||
ydocument.connect()
|
||||
connectBtn.textContent = 'Disconnect'
|
||||
}
|
||||
})
|
||||
|
||||
window.example = { provider, ydocument, type, prosemirrorView }
|
||||
|
||||
3668
package-lock.json
generated
3668
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -1,29 +1,30 @@
|
||||
{
|
||||
"name": "yjs",
|
||||
"version": "13.0.0-78",
|
||||
"description": "A ",
|
||||
"version": "13.0.0-84",
|
||||
"description": "Shared Editing Library",
|
||||
"main": "./dist/yjs.js",
|
||||
"module": "./dist/yjs.mjs'",
|
||||
"module": "./dist/yjs.mjs",
|
||||
"sideEffects": false,
|
||||
"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 examples/build && rollup -c",
|
||||
"serve-examples": "concurrently 'npm run watch' 'serve examples'",
|
||||
"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/",
|
||||
"postversion": "npm run lint && PRODUCTION=1 npm run dist && node ./dist/tests.js --repitition-time 1000",
|
||||
"preversion": "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"
|
||||
},
|
||||
"files": [
|
||||
"dist/*",
|
||||
"examples/*",
|
||||
"docs/*",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
"src/*",
|
||||
"tests/*",
|
||||
"docs/*"
|
||||
],
|
||||
"dictionaries": {
|
||||
"doc": "docs",
|
||||
@@ -53,28 +54,27 @@
|
||||
},
|
||||
"homepage": "http://y-js.org",
|
||||
"dependencies": {
|
||||
"lib0": "0.0.0"
|
||||
"lib0": "0.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"codemirror": "^5.42.0",
|
||||
"codemirror": "^5.46.0",
|
||||
"concurrently": "^3.6.1",
|
||||
"esdoc": "^1.1.0",
|
||||
"esdoc-standard-plugin": "^1.0.0",
|
||||
"jsdoc": "^3.5.5",
|
||||
"jsdoc": "^3.6.2",
|
||||
"live-server": "^1.2.1",
|
||||
"prosemirror-example-setup": "^1.0.1",
|
||||
"prosemirror-schema-basic": "^1.0.0",
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-view": "^1.6.5",
|
||||
"prosemirror-schema-basic": "^1.0.1",
|
||||
"prosemirror-state": "^1.2.3",
|
||||
"prosemirror-view": "^1.9.5",
|
||||
"quill": "^1.3.6",
|
||||
"quill-cursors": "^1.0.3",
|
||||
"rollup": "^1.1.2",
|
||||
"rollup": "^1.11.3",
|
||||
"rollup-cli": "^1.0.9",
|
||||
"rollup-plugin-commonjs": "^9.2.0",
|
||||
"rollup-plugin-node-resolve": "^4.0.0",
|
||||
"rollup-plugin-commonjs": "^9.3.4",
|
||||
"rollup-plugin-node-resolve": "^4.2.4",
|
||||
"rollup-plugin-terser": "^4.0.4",
|
||||
"standard": "^11.0.1",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^3.3.3333"
|
||||
"typescript": "^3.4.5",
|
||||
"y-protocols": "0.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +80,12 @@ export default [{
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
module: true,
|
||||
browser: true
|
||||
}),
|
||||
commonjs()
|
||||
mainFields: ['module', 'browser', 'main']
|
||||
})
|
||||
// commonjs()
|
||||
]
|
||||
}, {
|
||||
input: ['./examples/codemirror.js', './examples/textarea.js'], // './examples/quill.js', './examples/dom.js', './examples/prosemirror.js'
|
||||
input: ['./examples/prosemirror.js'], // './examples/textarea.js', './examples/quill.js', './examples/dom.js', './examples/codemirror.js'
|
||||
output: {
|
||||
dir: 'examples/build',
|
||||
format: 'esm',
|
||||
@@ -96,8 +95,7 @@ export default [{
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
module: true,
|
||||
browser: true
|
||||
mainFields: ['module', 'browser', 'main']
|
||||
}),
|
||||
commonjs(),
|
||||
...minificationPlugins
|
||||
|
||||
42
src/index.js
42
src/index.js
@@ -1,6 +1,6 @@
|
||||
|
||||
export {
|
||||
Y,
|
||||
Doc,
|
||||
Transaction,
|
||||
YArray as Array,
|
||||
YMap as Map,
|
||||
@@ -9,18 +9,38 @@ export {
|
||||
YXmlHook as XmlHook,
|
||||
YXmlElement as XmlElement,
|
||||
YXmlFragment as XmlFragment,
|
||||
createCursorFromTypeOffset,
|
||||
createCursorFromJSON,
|
||||
createAbsolutePositionFromCursor,
|
||||
writeCursor,
|
||||
readCursor,
|
||||
YXmlEvent,
|
||||
YMapEvent,
|
||||
YArrayEvent,
|
||||
YEvent,
|
||||
AbstractItem,
|
||||
AbstractStruct,
|
||||
GC,
|
||||
ItemBinary,
|
||||
ItemDeleted,
|
||||
ItemEmbed,
|
||||
ItemFormat,
|
||||
ItemJSON,
|
||||
ItemString,
|
||||
ItemType,
|
||||
AbstractType,
|
||||
RelativePosition,
|
||||
createRelativePositionFromTypeIndex,
|
||||
createRelativePositionFromJSON,
|
||||
createAbsolutePositionFromRelativePosition,
|
||||
compareRelativePositions,
|
||||
writeRelativePosition,
|
||||
readRelativePosition,
|
||||
ID,
|
||||
createID,
|
||||
compareIDs,
|
||||
getState,
|
||||
getStates,
|
||||
readStatesAsMap,
|
||||
writeStates,
|
||||
writeModel,
|
||||
readModel
|
||||
Snapshot,
|
||||
findRootTypeKey,
|
||||
typeListToArraySnapshot,
|
||||
typeMapGetSnapshot,
|
||||
iterateDeletedStructs,
|
||||
applyUpdate,
|
||||
encodeStateAsUpdate,
|
||||
encodeStateVector
|
||||
} from './internals.js'
|
||||
|
||||
@@ -2,18 +2,19 @@ export * from './utils/DeleteSet.js'
|
||||
export * from './utils/EventHandler.js'
|
||||
export * from './utils/ID.js'
|
||||
export * from './utils/isParentOf.js'
|
||||
export * from './utils/cursor.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/Y.js'
|
||||
export * from './utils/Doc.js'
|
||||
export * from './utils/YEvent.js'
|
||||
|
||||
export * from './types/AbstractType.js'
|
||||
export * from './types/YArray.js'
|
||||
export * from './types/YMap.js'
|
||||
export * from './types/YText.js'
|
||||
export * from './types/YXmlFragment.js'
|
||||
export * from './types/YXmlElement.js'
|
||||
export * from './types/YXmlEvent.js'
|
||||
export * from './types/YXmlHook.js'
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
getItemType,
|
||||
getItemCleanEnd,
|
||||
getItemCleanStart,
|
||||
YEvent, StructStore, ID, AbstractType, Y, Transaction // eslint-disable-line
|
||||
YEvent, StructStore, ID, AbstractType, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as error from 'lib0/error.js'
|
||||
@@ -27,6 +27,22 @@ import * as maplib from 'lib0/map.js'
|
||||
import * as set from 'lib0/set.js'
|
||||
import * as binary from 'lib0/binary.js'
|
||||
|
||||
/**
|
||||
* @param {AbstractItem} left
|
||||
* @param {AbstractItem} right
|
||||
* @return {boolean} If true, right is removed from the linked list and should be discarded
|
||||
*/
|
||||
export const mergeItemWith = (left, right) => {
|
||||
if (compareIDs(right.origin, left.lastId) && left.right === right && compareIDs(left.rightOrigin, right.rightOrigin)) {
|
||||
left.right = right.right
|
||||
if (left.right !== null) {
|
||||
left.right.left = left
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Split leftItem into two items
|
||||
* @param {Transaction} transaction
|
||||
@@ -60,6 +76,10 @@ export const splitItem = (transaction, leftItem, diff) => {
|
||||
}
|
||||
// right is more specific.
|
||||
transaction._mergeStructs.add(rightItem.id)
|
||||
// update parent._map
|
||||
if (rightItem.parentSub !== null && rightItem.right === null) {
|
||||
rightItem.parent._map.set(rightItem.parentSub, rightItem)
|
||||
}
|
||||
return rightItem
|
||||
}
|
||||
|
||||
@@ -133,7 +153,7 @@ export class AbstractItem extends AbstractStruct {
|
||||
* @private
|
||||
*/
|
||||
integrate (transaction) {
|
||||
const store = transaction.y.store
|
||||
const store = transaction.doc.store
|
||||
const id = this.id
|
||||
const parent = this.parent
|
||||
const parentSub = this.parentSub
|
||||
@@ -265,7 +285,7 @@ export class AbstractItem extends AbstractStruct {
|
||||
* @private
|
||||
*/
|
||||
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
|
||||
throw new Error('unimplemented')
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,25 +393,9 @@ export class AbstractItem extends AbstractStruct {
|
||||
* @private
|
||||
*/
|
||||
splitAt (transaction, diff) {
|
||||
throw new Error('unimplemented')
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractItem} right
|
||||
* @return {boolean}
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
mergeWith (right) {
|
||||
if (compareIDs(right.origin, this.lastId) && this.right === right && compareIDs(this.rightOrigin, right.rightOrigin)) {
|
||||
this.right = right.right
|
||||
if (this.right !== null) {
|
||||
this.right.left = this
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
/**
|
||||
* Mark this Item as deleted.
|
||||
*
|
||||
@@ -411,22 +415,24 @@ export class AbstractItem extends AbstractStruct {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
gcChildren (transaction, store) { }
|
||||
gcChildren (store) { }
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {boolean} parentGCd
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
gc (transaction, store) {
|
||||
gc (store, parentGCd) {
|
||||
if (!this.deleted) {
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
let r
|
||||
if (this.parent._item !== null && this.parent._item.deleted) {
|
||||
if (parentGCd) {
|
||||
r = new GC(this.id, this.length)
|
||||
} else {
|
||||
r = new ItemDeleted(this.id, this.left, this.origin, this.right, this.rightOrigin, this.parent, this.parentSub, this.length)
|
||||
@@ -442,7 +448,6 @@ export class AbstractItem extends AbstractStruct {
|
||||
}
|
||||
}
|
||||
replaceStruct(store, this, r)
|
||||
transaction._mergeStructs.add(r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,10 +615,16 @@ export const computeItemParams = (transaction, store, leftid, rightid, parentid,
|
||||
case GC:
|
||||
break
|
||||
default:
|
||||
parent = parentItem.type
|
||||
// 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 probably 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 = parentItem.type
|
||||
}
|
||||
}
|
||||
} else if (parentYKey !== null) {
|
||||
parent = transaction.y.get(parentYKey)
|
||||
parent = transaction.doc.get(parentYKey)
|
||||
} else if (left !== null) {
|
||||
if (left.constructor !== GC) {
|
||||
parent = left.parent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import {
|
||||
Y, StructStore, ID, Transaction // eslint-disable-line
|
||||
StructStore, ID, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
AbstractStruct,
|
||||
createID,
|
||||
addStruct,
|
||||
Y, StructStore, Transaction, ID // eslint-disable-line
|
||||
StructStore, Transaction, ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
@@ -48,7 +48,7 @@ export class GC extends AbstractStruct {
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
integrate (transaction) {
|
||||
addStruct(transaction.y.store, this)
|
||||
addStruct(transaction.doc.store, this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as buffer from 'lib0/buffer.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
@@ -27,7 +28,7 @@ export class ItemBinary extends AbstractItem {
|
||||
* @param {ID | null} rightOrigin
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {string | null} parentSub
|
||||
* @param {ArrayBuffer} content
|
||||
* @param {Uint8Array} content
|
||||
*/
|
||||
constructor (id, left, origin, right, rightOrigin, parent, parentSub, content) {
|
||||
super(id, left, origin, right, rightOrigin, parent, parentSub)
|
||||
@@ -54,7 +55,7 @@ export class ItemBinary extends AbstractItem {
|
||||
*/
|
||||
write (encoder, offset) {
|
||||
super.write(encoder, offset, structBinaryRefNumber)
|
||||
encoding.writePayload(encoder, this.content)
|
||||
encoding.writeVarUint8Array(encoder, this.content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +71,9 @@ export class ItemBinaryRef extends AbstractItemRef {
|
||||
constructor (decoder, id, info) {
|
||||
super(decoder, id, info)
|
||||
/**
|
||||
* @type {ArrayBuffer}
|
||||
* @type {Uint8Array}
|
||||
*/
|
||||
this.content = decoding.readPayload(decoder)
|
||||
this.content = buffer.copyUint8Array(decoding.readVarUint8Array(decoder))
|
||||
}
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
GC,
|
||||
splitItem,
|
||||
addToDeleteSet,
|
||||
Y, StructStore, Transaction, ID, AbstractType // eslint-disable-line
|
||||
mergeItemWith,
|
||||
StructStore, Transaction, ID, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -78,12 +79,24 @@ export class ItemDeleted extends AbstractItem {
|
||||
* @return {boolean}
|
||||
*/
|
||||
mergeWith (right) {
|
||||
if (super.mergeWith(right)) {
|
||||
if (mergeItemWith(this, right)) {
|
||||
this._len += right._len
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
* @param {boolean} parentGCd
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
gc (store, parentGCd) {
|
||||
if (parentGCd) {
|
||||
super.gc(store, parentGCd)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {number} offset
|
||||
|
||||
@@ -67,7 +67,7 @@ export class ItemEmbedRef extends AbstractItemRef {
|
||||
constructor (decoder, id, info) {
|
||||
super(decoder, id, info)
|
||||
/**
|
||||
* @type {ArrayBuffer}
|
||||
* @type {Object}
|
||||
*/
|
||||
this.embed = JSON.parse(decoding.readVarString(decoder))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
splitItem,
|
||||
changeItemRefOffset,
|
||||
GC,
|
||||
Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line
|
||||
mergeItemWith,
|
||||
Transaction, StructStore, ID, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -74,7 +75,7 @@ export class ItemJSON extends AbstractItem {
|
||||
* @return {boolean}
|
||||
*/
|
||||
mergeWith (right) {
|
||||
if (super.mergeWith(right)) {
|
||||
if (mergeItemWith(this, right)) {
|
||||
this.content = this.content.concat(right.content)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
splitItem,
|
||||
changeItemRefOffset,
|
||||
GC,
|
||||
Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line
|
||||
mergeItemWith,
|
||||
Transaction, StructStore, ID, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -73,7 +74,7 @@ export class ItemString extends AbstractItem {
|
||||
* @return {boolean}
|
||||
*/
|
||||
mergeWith (right) {
|
||||
if (super.mergeWith(right)) {
|
||||
if (mergeItemWith(this, right)) {
|
||||
this.string += right.string
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
readYXmlFragment,
|
||||
readYXmlHook,
|
||||
readYXmlText,
|
||||
StructStore, Y, GC, Transaction, ID, AbstractType // eslint-disable-line
|
||||
StructStore, GC, Transaction, ID, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
@@ -83,7 +83,7 @@ export class ItemType extends AbstractItem {
|
||||
*/
|
||||
integrate (transaction) {
|
||||
super.integrate(transaction)
|
||||
this.type._integrate(transaction.y, this)
|
||||
this.type._integrate(transaction.doc, this)
|
||||
}
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
@@ -100,26 +100,47 @@ export class ItemType extends AbstractItem {
|
||||
* @private
|
||||
*/
|
||||
delete (transaction) {
|
||||
super.delete(transaction)
|
||||
transaction.changed.delete(this.type)
|
||||
transaction.changedParentTypes.delete(this.type)
|
||||
this.gcChildren(transaction, transaction.y.store)
|
||||
if (!this.deleted) {
|
||||
super.delete(transaction)
|
||||
let item = this.type._start
|
||||
while (item !== null) {
|
||||
if (!item.deleted) {
|
||||
item.delete(transaction)
|
||||
} else {
|
||||
// Whis will be gc'd later and we want to merge it if possible
|
||||
// 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)
|
||||
}
|
||||
item = item.right
|
||||
}
|
||||
this.type._map.forEach(item => {
|
||||
if (!item.deleted) {
|
||||
item.delete(transaction)
|
||||
} else {
|
||||
// same as above
|
||||
transaction._mergeStructs.add(item.id)
|
||||
}
|
||||
})
|
||||
transaction.changed.delete(this.type)
|
||||
transaction.changedParentTypes.delete(this.type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
*/
|
||||
gcChildren (transaction, store) {
|
||||
gcChildren (store) {
|
||||
let item = this.type._start
|
||||
while (item !== null) {
|
||||
item.gc(transaction, store)
|
||||
item.gc(store, true)
|
||||
item = item.right
|
||||
}
|
||||
this.type._start = null
|
||||
this.type._map.forEach(item => {
|
||||
while (item !== null) {
|
||||
item.gc(transaction, store)
|
||||
item.gc(store, true)
|
||||
// @ts-ignore
|
||||
item = item.left
|
||||
}
|
||||
@@ -128,12 +149,12 @@ export class ItemType extends AbstractItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {boolean} parentGCd
|
||||
*/
|
||||
gc (transaction, store) {
|
||||
super.gc(transaction, store)
|
||||
this.gcChildren(transaction, store)
|
||||
gc (store, parentGCd) {
|
||||
this.gcChildren(store)
|
||||
super.gc(store, parentGCd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ItemBinary,
|
||||
createID,
|
||||
getItemCleanStart,
|
||||
Y, Snapshot, Transaction, EventHandler, YEvent, AbstractItem, // eslint-disable-line
|
||||
Doc, Snapshot, Transaction, EventHandler, YEvent, AbstractItem, // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as map from 'lib0/map.js'
|
||||
@@ -64,9 +64,9 @@ export class AbstractType {
|
||||
this._start = null
|
||||
/**
|
||||
* @private
|
||||
* @type {Y|null}
|
||||
* @type {Doc|null}
|
||||
*/
|
||||
this._y = null
|
||||
this.doc = null
|
||||
this._length = 0
|
||||
/**
|
||||
* Event handlers
|
||||
@@ -87,12 +87,12 @@ export class AbstractType {
|
||||
* * This type is sent to other client
|
||||
* * Observer functions are fired
|
||||
*
|
||||
* @param {Y} y The Yjs instance
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {ItemType|null} item
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
this._y = y
|
||||
this.doc = y
|
||||
this._item = item
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export class AbstractType {
|
||||
* @private
|
||||
*/
|
||||
_copy () {
|
||||
throw new Error('unimplemented')
|
||||
throw error.methodUnimplemented()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +182,7 @@ export class AbstractType {
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeArrayToArray = type => {
|
||||
export const typeListToArray = type => {
|
||||
const cs = []
|
||||
let n = type._start
|
||||
while (n !== null) {
|
||||
@@ -198,15 +198,38 @@ export const typeArrayToArray = type => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a provided function on once on overy element of this YArray.
|
||||
*
|
||||
* @param {AbstractType<any>} type
|
||||
* @param {function(any,number,AbstractType<any>):void} f A function to execute on every element of this YArray.
|
||||
* @param {Snapshot} snapshot
|
||||
* @return {Array<any>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeArrayForEach = (type, f) => {
|
||||
export const typeListToArraySnapshot = (type, snapshot) => {
|
||||
const cs = []
|
||||
let n = type._start
|
||||
while (n !== null) {
|
||||
if (n.countable && isVisible(n, snapshot)) {
|
||||
const c = n.getContent()
|
||||
for (let i = 0; i < c.length; i++) {
|
||||
cs.push(c[i])
|
||||
}
|
||||
}
|
||||
n = n.right
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a provided function on once on overy element of this YArray.
|
||||
*
|
||||
* @param {AbstractType<any>} type
|
||||
* @param {function(any,number,any):void} f A function to execute on every element of this YArray.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeListForEach = (type, f) => {
|
||||
let index = 0
|
||||
let n = type._start
|
||||
while (n !== null) {
|
||||
@@ -229,12 +252,12 @@ export const typeArrayForEach = (type, f) => {
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeArrayMap = (type, f) => {
|
||||
export const typeListMap = (type, f) => {
|
||||
/**
|
||||
* @type {Array<any>}
|
||||
*/
|
||||
const result = []
|
||||
typeArrayForEach(type, (c, i) => {
|
||||
typeListForEach(type, (c, i) => {
|
||||
result.push(f(c, i, type))
|
||||
})
|
||||
return result
|
||||
@@ -247,7 +270,7 @@ export const typeArrayMap = (type, f) => {
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeArrayCreateIterator = type => {
|
||||
export const typeListCreateIterator = type => {
|
||||
let n = type._start
|
||||
/**
|
||||
* @type {Array<any>|null}
|
||||
@@ -303,7 +326,7 @@ export const typeArrayCreateIterator = type => {
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeArrayForEachSnapshot = (type, f, snapshot) => {
|
||||
export const typeListForEachSnapshot = (type, f, snapshot) => {
|
||||
let index = 0
|
||||
let n = type._start
|
||||
while (n !== null) {
|
||||
@@ -325,7 +348,7 @@ export const typeArrayForEachSnapshot = (type, f, snapshot) => {
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeArrayGet = (type, index) => {
|
||||
export const typeListGet = (type, index) => {
|
||||
for (let n = type._start; n !== null; n = n.right) {
|
||||
if (!n.deleted && n.countable) {
|
||||
if (index < n.length) {
|
||||
@@ -340,12 +363,12 @@ export const typeArrayGet = (type, index) => {
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {AbstractItem?} referenceItem
|
||||
* @param {Array<Object<string,any>|Array<any>|number|string|ArrayBuffer>} content
|
||||
* @param {Array<Object<string,any>|Array<any>|number|string|Uint8Array>} content
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeArrayInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
|
||||
export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
|
||||
let left = referenceItem
|
||||
const right = referenceItem === null ? parent._start : referenceItem.right
|
||||
/**
|
||||
@@ -370,10 +393,9 @@ export const typeArrayInsertGenericsAfter = (transaction, parent, referenceItem,
|
||||
default:
|
||||
packJsonContent()
|
||||
switch (c.constructor) {
|
||||
case Uint8Array:
|
||||
case ArrayBuffer:
|
||||
// @ts-ignore c is definitely an ArrayBuffer
|
||||
left = new ItemBinary(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, c)
|
||||
// @ts-ignore
|
||||
left = new ItemBinary(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new Uint8Array(/** @type {Uint8Array} */ (c)))
|
||||
left.integrate(transaction)
|
||||
break
|
||||
default:
|
||||
@@ -393,14 +415,14 @@ export const typeArrayInsertGenericsAfter = (transaction, parent, referenceItem,
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {number} index
|
||||
* @param {Array<Object<string,any>|Array<any>|number|string|ArrayBuffer>} content
|
||||
* @param {Array<Object<string,any>|Array<any>|number|string|Uint8Array>} content
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeArrayInsertGenerics = (transaction, parent, index, content) => {
|
||||
export const typeListInsertGenerics = (transaction, parent, index, content) => {
|
||||
if (index === 0) {
|
||||
return typeArrayInsertGenericsAfter(transaction, parent, null, content)
|
||||
return typeListInsertGenericsAfter(transaction, parent, null, content)
|
||||
}
|
||||
let n = parent._start
|
||||
for (; n !== null; n = n.right) {
|
||||
@@ -408,14 +430,14 @@ export const typeArrayInsertGenerics = (transaction, parent, index, content) =>
|
||||
if (index <= n.length) {
|
||||
if (index < n.length) {
|
||||
// insert in-between
|
||||
getItemCleanStart(transaction, transaction.y.store, createID(n.id.client, n.id.clock + index))
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + index))
|
||||
}
|
||||
break
|
||||
}
|
||||
index -= n.length
|
||||
}
|
||||
}
|
||||
return typeArrayInsertGenericsAfter(transaction, parent, n, content)
|
||||
return typeListInsertGenericsAfter(transaction, parent, n, content)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -427,17 +449,14 @@ export const typeArrayInsertGenerics = (transaction, parent, index, content) =>
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const typeArrayDelete = (transaction, parent, index, length) => {
|
||||
export const typeListDelete = (transaction, parent, index, length) => {
|
||||
if (length === 0) { return }
|
||||
let n = parent._start
|
||||
// compute the first item to be deleted
|
||||
for (; n !== null; n = n.right) {
|
||||
for (; n !== null && index > 0; n = n.right) {
|
||||
if (!n.deleted && n.countable) {
|
||||
if (index <= n.length) {
|
||||
if (index < n.length && index > 0) {
|
||||
n = getItemCleanStart(transaction, transaction.y.store, createID(n.id.client, n.id.clock + index))
|
||||
}
|
||||
break
|
||||
if (index < n.length) {
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + index))
|
||||
}
|
||||
index -= n.length
|
||||
}
|
||||
@@ -446,7 +465,7 @@ export const typeArrayDelete = (transaction, parent, index, length) => {
|
||||
while (length > 0 && n !== null) {
|
||||
if (!n.deleted) {
|
||||
if (length < n.length) {
|
||||
getItemCleanStart(transaction, transaction.y.store, createID(n.id.client, n.id.clock + length))
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + length))
|
||||
}
|
||||
n.delete(transaction)
|
||||
length -= n.length
|
||||
@@ -477,7 +496,7 @@ export const typeMapDelete = (transaction, parent, key) => {
|
||||
* @param {Transaction} transaction
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {string} key
|
||||
* @param {Object|number|Array<any>|string|ArrayBuffer|AbstractType<any>} value
|
||||
* @param {Object|number|Array<any>|string|Uint8Array|AbstractType<any>} value
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
@@ -495,7 +514,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
|
||||
case String:
|
||||
new ItemJSON(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, [value]).integrate(transaction)
|
||||
break
|
||||
case ArrayBuffer:
|
||||
case Uint8Array:
|
||||
new ItemBinary(nextID(transaction), left, left === null ? null : left.lastId, null, null, parent, key, value).integrate(transaction)
|
||||
break
|
||||
default:
|
||||
@@ -510,7 +529,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
|
||||
/**
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {string} key
|
||||
* @return {Object<string,any>|number|Array<any>|string|ArrayBuffer|AbstractType<any>|undefined}
|
||||
* @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
@@ -522,7 +541,7 @@ export const typeMapGet = (parent, key) => {
|
||||
|
||||
/**
|
||||
* @param {AbstractType<any>} parent
|
||||
* @return {Object<string,Object<string,any>|number|Array<any>|string|ArrayBuffer|AbstractType<any>|undefined>}
|
||||
* @return {Object<string,Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
@@ -557,7 +576,7 @@ export const typeMapHas = (parent, key) => {
|
||||
* @param {AbstractType<any>} parent
|
||||
* @param {string} key
|
||||
* @param {Snapshot} snapshot
|
||||
* @return {Object<string,any>|number|Array<any>|string|ArrayBuffer|AbstractType<any>|undefined}
|
||||
* @return {Object<string,any>|number|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
@@ -577,4 +596,4 @@ export const typeMapGetSnapshot = (parent, key, snapshot) => {
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const createMapIterator = map => iterator.iteratorFilter(map.entries(), entry => !entry[1].deleted)
|
||||
export const createMapIterator = map => iterator.iteratorFilter(map.entries(), /** @param {any} entry */ entry => !entry[1].deleted)
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
import {
|
||||
YEvent,
|
||||
AbstractType,
|
||||
typeArrayGet,
|
||||
typeArrayToArray,
|
||||
typeArrayForEach,
|
||||
typeArrayCreateIterator,
|
||||
typeArrayInsertGenerics,
|
||||
typeArrayDelete,
|
||||
typeArrayMap,
|
||||
typeListGet,
|
||||
typeListToArray,
|
||||
typeListForEach,
|
||||
typeListCreateIterator,
|
||||
typeListInsertGenerics,
|
||||
typeListDelete,
|
||||
typeListMap,
|
||||
YArrayRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
Y, Transaction, ItemType, // eslint-disable-line
|
||||
Doc, Transaction, ItemType, // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
@@ -58,7 +58,7 @@ export class YArray extends AbstractType {
|
||||
* * This type is sent to other client
|
||||
* * Observer functions are fired
|
||||
*
|
||||
* @param {Y} y The Yjs instance
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {ItemType} item
|
||||
*
|
||||
* @private
|
||||
@@ -84,6 +84,59 @@ export class YArray extends AbstractType {
|
||||
callTypeObservers(this, transaction, new YArrayEvent(this, transaction))
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts new content at an index.
|
||||
*
|
||||
* Important: This function expects an array of content. Not just a content
|
||||
* object. The reason for this "weirdness" is that inserting several elements
|
||||
* is very efficient when it is done as a single operation.
|
||||
*
|
||||
* @example
|
||||
* // Insert character 'a' at position 0
|
||||
* yarray.insert(0, ['a'])
|
||||
* // Insert numbers 1, 2 at position 1
|
||||
* yarray.insert(1, [1, 2])
|
||||
*
|
||||
* @param {number} index The index to insert content at.
|
||||
* @param {Array<T>} content The array of content
|
||||
*/
|
||||
insert (index, content) {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeListInsertGenerics(transaction, this, index, content)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore _prelimContent is defined because this is not yet integrated
|
||||
this._prelimContent.splice(index, 0, ...content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends content to this YArray.
|
||||
*
|
||||
* @param {Array<T>} content Array of content to append.
|
||||
*/
|
||||
push (content) {
|
||||
this.insert(this.length, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes elements starting from an index.
|
||||
*
|
||||
* @param {number} index Index at which to start deleting elements
|
||||
* @param {number} length The number of elements to remove. Defaults to 1.
|
||||
*/
|
||||
delete (index, length = 1) {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeListDelete(transaction, this, index, length)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore _prelimContent is defined because this is not yet integrated
|
||||
this._prelimContent.splice(index, length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the i-th element from a YArray.
|
||||
*
|
||||
@@ -91,7 +144,7 @@ export class YArray extends AbstractType {
|
||||
* @return {T}
|
||||
*/
|
||||
get (index) {
|
||||
return typeArrayGet(this, index)
|
||||
return typeListGet(this, index)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +153,7 @@ export class YArray extends AbstractType {
|
||||
* @return {Array<T>}
|
||||
*/
|
||||
toArray () {
|
||||
return typeArrayToArray(this)
|
||||
return typeListToArray(this)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,76 +176,23 @@ export class YArray extends AbstractType {
|
||||
*/
|
||||
map (f) {
|
||||
// @ts-ignore
|
||||
return typeArrayMap(this, f)
|
||||
return typeListMap(this, f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a provided function on once on overy element of this YArray.
|
||||
*
|
||||
* @param {function(T,number):void} f A function to execute on every element of this YArray.
|
||||
* @param {function(T,number,YArray<T>):void} f A function to execute on every element of this YArray.
|
||||
*/
|
||||
forEach (f) {
|
||||
typeArrayForEach(this, f)
|
||||
typeListForEach(this, f)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {IterableIterator<T>}
|
||||
*/
|
||||
[Symbol.iterator] () {
|
||||
return typeArrayCreateIterator(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes elements starting from an index.
|
||||
*
|
||||
* @param {number} index Index at which to start deleting elements
|
||||
* @param {number} length The number of elements to remove. Defaults to 1.
|
||||
*/
|
||||
delete (index, length = 1) {
|
||||
if (this._y !== null) {
|
||||
transact(this._y, transaction => {
|
||||
typeArrayDelete(transaction, this, index, length)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore _prelimContent is defined because this is not yet integrated
|
||||
this._prelimContent.splice(index, length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts new content at an index.
|
||||
*
|
||||
* Important: This function expects an array of content. Not just a content
|
||||
* object. The reason for this "weirdness" is that inserting several elements
|
||||
* is very efficient when it is done as a single operation.
|
||||
*
|
||||
* @example
|
||||
* // Insert character 'a' at position 0
|
||||
* yarray.insert(0, ['a'])
|
||||
* // Insert numbers 1, 2 at position 1
|
||||
* yarray.insert(1, [1, 2])
|
||||
*
|
||||
* @param {number} index The index to insert content at.
|
||||
* @param {Array<T>} content The array of content
|
||||
*/
|
||||
insert (index, content) {
|
||||
if (this._y !== null) {
|
||||
transact(this._y, transaction => {
|
||||
typeArrayInsertGenerics(transaction, this, index, content)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore _prelimContent is defined because this is not yet integrated
|
||||
this._prelimContent.splice(index, 0, ...content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends content to this YArray.
|
||||
*
|
||||
* @param {Array<T>} content Array of content to append.
|
||||
*/
|
||||
push (content) {
|
||||
this.insert(this.length, content)
|
||||
return typeListCreateIterator(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
YMapRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
Y, Transaction, ItemType, // eslint-disable-line
|
||||
Doc, Transaction, ItemType, // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -38,7 +38,7 @@ export class YMapEvent extends YEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T number|string|Object|Array|ArrayBuffer
|
||||
* @template T number|string|Object|Array|Uint8Array
|
||||
* A shared Map implementation.
|
||||
*
|
||||
* @extends AbstractType<YMapEvent<T>>
|
||||
@@ -60,7 +60,7 @@ export class YMap extends AbstractType {
|
||||
* * This type is sent to other client
|
||||
* * Observer functions are fired
|
||||
*
|
||||
* @param {Y} y The Yjs instance
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {ItemType} item
|
||||
*
|
||||
* @private
|
||||
@@ -110,15 +110,25 @@ export class YMap extends AbstractType {
|
||||
* @return {Iterator<string>}
|
||||
*/
|
||||
keys () {
|
||||
return iterator.iteratorMap(createMapIterator(this._map), v => v[0])
|
||||
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value for each element in the YMap Type.
|
||||
* Returns the keys for each element in the YMap Type.
|
||||
*
|
||||
* @return {IterableIterator<T>}
|
||||
* @return {Iterator<string>}
|
||||
*/
|
||||
values () {
|
||||
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].getContent()[v[1].length - 1])
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an Iterator of [key, value] pairs
|
||||
*
|
||||
* @return {IterableIterator<any>}
|
||||
*/
|
||||
entries () {
|
||||
return iterator.iteratorMap(createMapIterator(this._map), v => v[1].getContent()[0])
|
||||
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => [v[0], v[1].getContent()[v[1].length - 1]])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,8 +144,8 @@ export class YMap extends AbstractType {
|
||||
* @param {string} key The key of the element to remove.
|
||||
*/
|
||||
delete (key) {
|
||||
if (this._y !== null) {
|
||||
transact(this._y, transaction => {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeMapDelete(transaction, this, key)
|
||||
})
|
||||
} else {
|
||||
@@ -151,8 +161,8 @@ export class YMap extends AbstractType {
|
||||
* @param {T} value The value of the element to add
|
||||
*/
|
||||
set (key, value) {
|
||||
if (this._y !== null) {
|
||||
transact(this._y, transaction => {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeMapSet(transaction, this, key, value)
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
YTextRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
Y, ItemType, AbstractItem, Snapshot, StructStore, Transaction // eslint-disable-line
|
||||
Doc, ItemType, AbstractItem, Snapshot, StructStore, Transaction // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
@@ -147,7 +147,7 @@ const insertNegatedAttributes = (transaction, parent, left, right, negatedAttrib
|
||||
left = new ItemFormat(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, key, val)
|
||||
left.integrate(transaction)
|
||||
}
|
||||
return {left, right}
|
||||
return { left, right }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,10 +215,10 @@ const insertAttributes = (transaction, parent, left, right, currentAttributes, a
|
||||
// insert format-start items
|
||||
for (let key in attributes) {
|
||||
const val = attributes[key]
|
||||
const currentVal = currentAttributes.get(key)
|
||||
const currentVal = currentAttributes.get(key) || null
|
||||
if (currentVal !== val) {
|
||||
// save negated attribute (set null if currentVal undefined)
|
||||
negatedAttributes.set(key, currentVal || null)
|
||||
negatedAttributes.set(key, currentVal)
|
||||
left = new ItemFormat(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, key, val)
|
||||
left.integrate(transaction)
|
||||
}
|
||||
@@ -303,7 +303,7 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
|
||||
case ItemEmbed:
|
||||
case ItemString:
|
||||
if (length < right.length) {
|
||||
getItemCleanStart(transaction, transaction.y.store, createID(right.id.client, right.id.clock + length))
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(right.id.client, right.id.clock + length))
|
||||
}
|
||||
length -= right.length
|
||||
break
|
||||
@@ -337,7 +337,7 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
|
||||
case ItemEmbed:
|
||||
case ItemString:
|
||||
if (length < right.length) {
|
||||
getItemCleanStart(transaction, transaction.y.store, createID(right.id.client, right.id.clock + length))
|
||||
getItemCleanStart(transaction, transaction.doc.store, createID(right.id.client, right.id.clock + length))
|
||||
}
|
||||
length -= right.length
|
||||
right.delete(transaction)
|
||||
@@ -388,7 +388,7 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
|
||||
/**
|
||||
* Event that describes the changes on a YText type.
|
||||
*/
|
||||
class YTextEvent extends YEvent {
|
||||
export class YTextEvent extends YEvent {
|
||||
/**
|
||||
* @param {YText} ytext
|
||||
* @param {Transaction} transaction
|
||||
@@ -411,7 +411,7 @@ class YTextEvent extends YEvent {
|
||||
*/
|
||||
get delta () {
|
||||
if (this._delta === null) {
|
||||
const y = this.target._y
|
||||
const y = this.target.doc
|
||||
// @ts-ignore
|
||||
transact(y, transaction => {
|
||||
/**
|
||||
@@ -475,11 +475,13 @@ class YTextEvent extends YEvent {
|
||||
switch (item.constructor) {
|
||||
case ItemEmbed:
|
||||
if (this.adds(item)) {
|
||||
addOp()
|
||||
action = 'insert'
|
||||
// @ts-ignore item is ItemFormat
|
||||
insert = item.embed
|
||||
addOp()
|
||||
if (!this.deletes(item)) {
|
||||
addOp()
|
||||
action = 'insert'
|
||||
// @ts-ignore item is ItemFormat
|
||||
insert = item.embed
|
||||
addOp()
|
||||
}
|
||||
} else if (this.deletes(item)) {
|
||||
if (action !== 'delete') {
|
||||
addOp()
|
||||
@@ -496,12 +498,14 @@ class YTextEvent extends YEvent {
|
||||
break
|
||||
case ItemString:
|
||||
if (this.adds(item)) {
|
||||
if (action !== 'insert') {
|
||||
addOp()
|
||||
action = 'insert'
|
||||
if (!this.deletes(item)) {
|
||||
if (action !== 'insert') {
|
||||
addOp()
|
||||
action = 'insert'
|
||||
}
|
||||
// @ts-ignore
|
||||
insert += item.string
|
||||
}
|
||||
// @ts-ignore
|
||||
insert += item.string
|
||||
} else if (this.deletes(item)) {
|
||||
if (action !== 'delete') {
|
||||
addOp()
|
||||
@@ -518,23 +522,25 @@ class YTextEvent extends YEvent {
|
||||
break
|
||||
case ItemFormat:
|
||||
if (this.adds(item)) {
|
||||
// @ts-ignore item is ItemFormat
|
||||
const curVal = currentAttributes.get(item.key) || null
|
||||
// @ts-ignore item is ItemFormat
|
||||
if (curVal !== item.value) {
|
||||
if (action === 'retain') {
|
||||
addOp()
|
||||
}
|
||||
if (!this.deletes(item)) {
|
||||
// @ts-ignore item is ItemFormat
|
||||
if (item.value === (oldAttributes.get(item.key) || null)) {
|
||||
const curVal = currentAttributes.get(item.key) || null
|
||||
// @ts-ignore item is ItemFormat
|
||||
if (curVal !== item.value) {
|
||||
if (action === 'retain') {
|
||||
addOp()
|
||||
}
|
||||
// @ts-ignore item is ItemFormat
|
||||
delete attributes[item.key]
|
||||
if (item.value === (oldAttributes.get(item.key) || null)) {
|
||||
// @ts-ignore item is ItemFormat
|
||||
delete attributes[item.key]
|
||||
} else {
|
||||
// @ts-ignore item is ItemFormat
|
||||
attributes[item.key] = item.value
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore item is ItemFormat
|
||||
attributes[item.key] = item.value
|
||||
item.delete(transaction)
|
||||
}
|
||||
} else {
|
||||
item.delete(transaction)
|
||||
}
|
||||
} else if (this.deletes(item)) {
|
||||
// @ts-ignore item is ItemFormat
|
||||
@@ -617,10 +623,11 @@ export class YText extends AbstractType {
|
||||
constructor (string) {
|
||||
super()
|
||||
/**
|
||||
* @type {Array<string>?}
|
||||
* Array of pending operations on this type
|
||||
* @type {Array<function():void>?}
|
||||
* @private
|
||||
*/
|
||||
this._prelimContent = string !== undefined ? [string] : []
|
||||
this._pending = string !== undefined ? [() => this.insert(0, string)] : []
|
||||
}
|
||||
|
||||
get length () {
|
||||
@@ -628,16 +635,20 @@ export class YText extends AbstractType {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y} y
|
||||
* @param {Doc} y
|
||||
* @param {ItemType} item
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_integrate (y, item) {
|
||||
super._integrate(y, item)
|
||||
// @ts-ignore this._prelimContent is still defined
|
||||
this.insert(0, this._prelimContent.join(''))
|
||||
this._prelimContent = null
|
||||
try {
|
||||
// @ts-ignore this._prelimContent is still defined
|
||||
this._pending.forEach(f => f())
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
this._pending = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,10 +663,6 @@ export class YText extends AbstractType {
|
||||
callTypeObservers(this, transaction, new YTextEvent(this, transaction))
|
||||
}
|
||||
|
||||
toDom () {
|
||||
return document.createTextNode(this.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unformatted string representation of this YText type.
|
||||
*
|
||||
@@ -677,40 +684,6 @@ export class YText extends AbstractType {
|
||||
return str
|
||||
}
|
||||
|
||||
toDomString () {
|
||||
// @ts-ignore
|
||||
return this.toDelta().map(delta => {
|
||||
const nestedNodes = []
|
||||
for (let nodeName in delta.attributes) {
|
||||
const attrs = []
|
||||
for (let key in delta.attributes[nodeName]) {
|
||||
attrs.push({ key, value: delta.attributes[nodeName][key] })
|
||||
}
|
||||
// sort attributes to get a unique order
|
||||
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
|
||||
nestedNodes.push({ nodeName, attrs })
|
||||
}
|
||||
// sort node order to get a unique order
|
||||
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
|
||||
// now convert to dom string
|
||||
let str = ''
|
||||
for (let i = 0; i < nestedNodes.length; i++) {
|
||||
const node = nestedNodes[i]
|
||||
str += `<${node.nodeName}`
|
||||
for (let j = 0; j < node.attrs.length; j++) {
|
||||
const attr = node.attrs[i]
|
||||
str += ` ${attr.key}="${attr.value}"`
|
||||
}
|
||||
str += '>'
|
||||
}
|
||||
str += delta.insert
|
||||
for (let i = nestedNodes.length - 1; i >= 0; i--) {
|
||||
str += `</${nestedNodes[i].nodeName}>`
|
||||
}
|
||||
return str
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a {@link Delta} on this shared YText type.
|
||||
*
|
||||
@@ -719,8 +692,8 @@ export class YText extends AbstractType {
|
||||
* @public
|
||||
*/
|
||||
applyDelta (delta) {
|
||||
if (this._y !== null) {
|
||||
transact(this._y, transaction => {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
/**
|
||||
* @type {ItemListPosition}
|
||||
*/
|
||||
@@ -737,6 +710,9 @@ export class YText extends AbstractType {
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this._pending.push(() => this.applyDelta(delta))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,12 +809,15 @@ export class YText extends AbstractType {
|
||||
if (text.length <= 0) {
|
||||
return
|
||||
}
|
||||
const y = this._y
|
||||
const y = this.doc
|
||||
if (y !== null) {
|
||||
transact(y, transaction => {
|
||||
const {left, right, currentAttributes} = findPosition(transaction, y.store, this, index)
|
||||
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
|
||||
insertText(transaction, this, left, right, currentAttributes, text, attributes)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this._pending.push(() => this.insert(index, text, attributes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,12 +835,15 @@ export class YText extends AbstractType {
|
||||
if (embed.constructor !== Object) {
|
||||
throw new Error('Embed must be an Object')
|
||||
}
|
||||
const y = this._y
|
||||
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)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this._pending.push(() => this.insertEmbed(index, embed, attributes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,12 +859,15 @@ export class YText extends AbstractType {
|
||||
if (length === 0) {
|
||||
return
|
||||
}
|
||||
const y = this._y
|
||||
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)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this._pending.push(() => this.delete(index, length))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,7 +882,7 @@ export class YText extends AbstractType {
|
||||
* @public
|
||||
*/
|
||||
format (index, length, attributes) {
|
||||
const y = this._y
|
||||
const y = this.doc
|
||||
if (y !== null) {
|
||||
transact(y, transaction => {
|
||||
let { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
|
||||
@@ -906,6 +891,9 @@ export class YText extends AbstractType {
|
||||
}
|
||||
formatText(transaction, this, left, right, currentAttributes, length, attributes)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this._pending.push(() => this.format(index, length, attributes))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,244 +1,19 @@
|
||||
/**
|
||||
* @module YXml
|
||||
*/
|
||||
|
||||
import {
|
||||
YXmlEvent,
|
||||
AbstractType,
|
||||
typeArrayMap,
|
||||
typeArrayForEach,
|
||||
YXmlFragment,
|
||||
transact,
|
||||
typeMapDelete,
|
||||
typeMapSet,
|
||||
typeMapGet,
|
||||
typeMapGetAll,
|
||||
typeArrayInsertGenerics,
|
||||
typeArrayDelete,
|
||||
typeMapSet,
|
||||
typeMapDelete,
|
||||
typeListForEach,
|
||||
YXmlElementRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
Y, Transaction, ItemType, YXmlText, YXmlHook, Snapshot // eslint-disable-line
|
||||
Snapshot, Doc, ItemType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
/**
|
||||
* Define the elements to which a set of CSS queries apply.
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors}
|
||||
*
|
||||
* @example
|
||||
* query = '.classSelector'
|
||||
* query = 'nodeSelector'
|
||||
* query = '#idSelector'
|
||||
*
|
||||
* @typedef {string} CSS_Selector
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dom filter function.
|
||||
*
|
||||
* @callback domFilter
|
||||
* @param {string} nodeName The nodeName of the element
|
||||
* @param {Map} attributes The map of attributes.
|
||||
* @return {boolean} Whether to include the Dom node in the YXmlElement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
|
||||
* position within them.
|
||||
*
|
||||
* Can be created with {@link YXmlFragment#createTreeWalker}
|
||||
*
|
||||
* @public
|
||||
* @implements {IterableIterator}
|
||||
*/
|
||||
export class YXmlTreeWalker {
|
||||
/**
|
||||
* @param {YXmlFragment | YXmlElement} root
|
||||
* @param {function(AbstractType<any>):boolean} [f]
|
||||
*/
|
||||
constructor (root, f = () => true) {
|
||||
this._filter = f
|
||||
this._root = root
|
||||
/**
|
||||
* @type {ItemType | null}
|
||||
*/
|
||||
// @ts-ignore
|
||||
this._currentNode = root._start
|
||||
this._firstCall = true
|
||||
}
|
||||
|
||||
[Symbol.iterator] () {
|
||||
return this
|
||||
}
|
||||
/**
|
||||
* Get the next node.
|
||||
*
|
||||
* @return {IteratorResult<YXmlElement|YXmlText|YXmlHook>} The next node.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
next () {
|
||||
let n = this._currentNode
|
||||
if (n !== null && (!this._firstCall || n.deleted || !this._filter(n.type))) { // if first call, we check if we can use the first item
|
||||
do {
|
||||
if (!n.deleted && (n.type.constructor === YXmlElement || n.type.constructor === YXmlFragment) && n.type._start !== null) {
|
||||
// walk down in the tree
|
||||
// @ts-ignore
|
||||
n = n.type._start
|
||||
} else {
|
||||
// walk right or up in the tree
|
||||
while (n !== null) {
|
||||
if (n.right !== null) {
|
||||
// @ts-ignore
|
||||
n = n.right
|
||||
break
|
||||
} else if (n.parent === this._root) {
|
||||
n = null
|
||||
} else {
|
||||
n = n.parent._item
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (n !== null && (n.deleted || !this._filter(n.type)))
|
||||
}
|
||||
this._firstCall = false
|
||||
this._currentNode = n
|
||||
if (n === null) {
|
||||
// @ts-ignore return undefined if done=true (the expected result)
|
||||
return { value: undefined, done: true }
|
||||
}
|
||||
// @ts-ignore
|
||||
return { value: n.type, done: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a list of {@link YXmlElement}.and {@link YXmlText} types.
|
||||
* A YxmlFragment is similar to a {@link YXmlElement}, but it does not have a
|
||||
* nodeName and it does not have attributes. Though it can be bound to a DOM
|
||||
* element - in this case the attributes and the nodeName are not shared.
|
||||
*
|
||||
* @public
|
||||
* @extends AbstractType<YXmlEvent>
|
||||
*/
|
||||
export class YXmlFragment extends AbstractType {
|
||||
/**
|
||||
* Create a subtree of childNodes.
|
||||
*
|
||||
* @example
|
||||
* const walker = elem.createTreeWalker(dom => dom.nodeName === 'div')
|
||||
* for (let node in walker) {
|
||||
* // `node` is a div node
|
||||
* nop(node)
|
||||
* }
|
||||
*
|
||||
* @param {function(AbstractType<any>):boolean} filter Function that is called on each child element and
|
||||
* returns a Boolean indicating whether the child
|
||||
* is to be included in the subtree.
|
||||
* @return {YXmlTreeWalker} A subtree and a position within it.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
createTreeWalker (filter) {
|
||||
return new YXmlTreeWalker(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first YXmlElement that matches the query.
|
||||
* Similar to DOM's {@link querySelector}.
|
||||
*
|
||||
* Query support:
|
||||
* - tagname
|
||||
* TODO:
|
||||
* - id
|
||||
* - attribute
|
||||
*
|
||||
* @param {CSS_Selector} query The query on the children.
|
||||
* @return {YXmlElement|YXmlText|YXmlHook|null} The first element that matches the query or null.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
querySelector (query) {
|
||||
query = query.toUpperCase()
|
||||
// @ts-ignore
|
||||
const iterator = new YXmlTreeWalker(this, element => element.nodeName === query)
|
||||
const next = iterator.next()
|
||||
if (next.done) {
|
||||
return null
|
||||
} else {
|
||||
return next.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all YXmlElements that match the query.
|
||||
* Similar to Dom's {@link querySelectorAll}.
|
||||
*
|
||||
* @todo Does not yet support all queries. Currently only query by tagName.
|
||||
*
|
||||
* @param {CSS_Selector} query The query on the children
|
||||
* @return {Array<YXmlElement|YXmlText|YXmlHook|null>} The elements that match this query.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
querySelectorAll (query) {
|
||||
query = query.toUpperCase()
|
||||
// @ts-ignore
|
||||
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === 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.
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
callTypeObservers(this, transaction, new YXmlEvent(this, parentSubs, transaction))
|
||||
}
|
||||
|
||||
toString () {
|
||||
return this.toDomString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string representation of all the children of this YXmlFragment.
|
||||
*
|
||||
* @return {string} The string representation of all children.
|
||||
*/
|
||||
toDomString () {
|
||||
return typeArrayMap(this, xml => xml.toDomString()).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Dom Element that mirrors this YXmlElement.
|
||||
*
|
||||
* @param {Document} [_document=document] The document object (you must define
|
||||
* this when calling this method in
|
||||
* nodejs)
|
||||
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
|
||||
* are presented in the DOM
|
||||
* @param {any} [binding] You should not set this property. This is
|
||||
* used if DomBinding wants to create a
|
||||
* association to the created DOM type.
|
||||
* @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
toDom (_document = document, hooks = {}, binding) {
|
||||
const fragment = _document.createDocumentFragment()
|
||||
if (binding !== undefined) {
|
||||
binding._createAssociation(fragment, this)
|
||||
}
|
||||
typeArrayForEach(this, xmlType => {
|
||||
fragment.insertBefore(xmlType.toDom(_document, hooks, binding), null)
|
||||
})
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An YXmlElement imitates the behavior of a
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
|
||||
@@ -250,11 +25,6 @@ export class YXmlElement extends YXmlFragment {
|
||||
constructor (nodeName = 'UNDEFINED') {
|
||||
super()
|
||||
this.nodeName = nodeName.toUpperCase()
|
||||
/**
|
||||
* @type {Array<any>|null}
|
||||
* @private
|
||||
*/
|
||||
this._prelimContent = []
|
||||
/**
|
||||
* @type {Map<string, any>|null}
|
||||
* @private
|
||||
@@ -269,7 +39,7 @@ export class YXmlElement extends YXmlFragment {
|
||||
* * This type is sent to other client
|
||||
* * Observer functions are fired
|
||||
*
|
||||
* @param {Y} y The Yjs instance
|
||||
* @param {Doc} y The Yjs instance
|
||||
* @param {ItemType} item
|
||||
* @private
|
||||
*/
|
||||
@@ -295,20 +65,16 @@ export class YXmlElement extends YXmlFragment {
|
||||
return new YXmlElement(this.nodeName)
|
||||
}
|
||||
|
||||
toString () {
|
||||
return this.toDomString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string representation of this YXmlElement.
|
||||
* Returns the XML serialization of this YXmlElement.
|
||||
* The attributes are ordered by attribute-name, so you can easily use this
|
||||
* method to compare YXmlElements
|
||||
*
|
||||
* @return {String} The string representation of this type.
|
||||
* @return {string} The string representation of this type.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
toDomString () {
|
||||
toString () {
|
||||
const attrs = this.getAttributes()
|
||||
const stringBuilder = []
|
||||
const keys = []
|
||||
@@ -323,7 +89,7 @@ export class YXmlElement extends YXmlFragment {
|
||||
}
|
||||
const nodeName = this.nodeName.toLocaleLowerCase()
|
||||
const attrsString = stringBuilder.length > 0 ? ' ' + stringBuilder.join(' ') : ''
|
||||
return `<${nodeName}${attrsString}>${super.toDomString()}</${nodeName}>`
|
||||
return `<${nodeName}${attrsString}>${super.toString()}</${nodeName}>`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,8 +100,8 @@ export class YXmlElement extends YXmlFragment {
|
||||
* @public
|
||||
*/
|
||||
removeAttribute (attributeName) {
|
||||
if (this._y !== null) {
|
||||
transact(this._y, transaction => {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeMapDelete(transaction, this, attributeName)
|
||||
})
|
||||
} else {
|
||||
@@ -353,8 +119,8 @@ export class YXmlElement extends YXmlFragment {
|
||||
* @public
|
||||
*/
|
||||
setAttribute (attributeName, attributeValue) {
|
||||
if (this._y !== null) {
|
||||
transact(this._y, transaction => {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeMapSet(transaction, this, attributeName, attributeValue)
|
||||
})
|
||||
} else {
|
||||
@@ -389,44 +155,6 @@ export class YXmlElement extends YXmlFragment {
|
||||
return typeMapGetAll(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts new content at an index.
|
||||
*
|
||||
* @example
|
||||
* // Insert character 'a' at position 0
|
||||
* xml.insert(0, [new Y.XmlText('text')])
|
||||
*
|
||||
* @param {number} index The index to insert content at
|
||||
* @param {Array<YXmlElement|YXmlText>} content The array of content
|
||||
*/
|
||||
insert (index, content) {
|
||||
if (this._y !== null) {
|
||||
transact(this._y, transaction => {
|
||||
typeArrayInsertGenerics(transaction, this, index, content)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore _prelimContent is defined because this is not yet integrated
|
||||
this._prelimContent.splice(index, 0, ...content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes elements starting from an index.
|
||||
*
|
||||
* @param {number} index Index at which to start deleting elements
|
||||
* @param {number} [length=1] The number of elements to remove. Defaults to 1.
|
||||
*/
|
||||
delete (index, length = 1) {
|
||||
if (this._y !== null) {
|
||||
transact(this._y, transaction => {
|
||||
typeArrayDelete(transaction, this, index, length)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore _prelimContent is defined because this is not yet integrated
|
||||
this._prelimContent.splice(index, length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Dom Element that mirrors this YXmlElement.
|
||||
*
|
||||
@@ -442,14 +170,14 @@ export class YXmlElement extends YXmlFragment {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
toDom (_document = document, hooks = {}, binding) {
|
||||
toDOM (_document = document, hooks = {}, binding) {
|
||||
const dom = _document.createElement(this.nodeName)
|
||||
let attrs = this.getAttributes()
|
||||
for (let key in attrs) {
|
||||
dom.setAttribute(key, attrs[key])
|
||||
}
|
||||
typeArrayForEach(this, yxml => {
|
||||
dom.appendChild(yxml.toDom(_document, hooks, binding))
|
||||
typeListForEach(this, yxml => {
|
||||
dom.appendChild(yxml.toDOM(_document, hooks, binding))
|
||||
})
|
||||
if (binding !== undefined) {
|
||||
binding._createAssociation(dom, this)
|
||||
@@ -480,11 +208,3 @@ export class YXmlElement extends YXmlFragment {
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlElement = decoder => new YXmlElement(decoding.readVarString(decoder))
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {YXmlFragment}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlFragment = decoder => new YXmlFragment()
|
||||
|
||||
313
src/types/YXmlFragment.js
Normal file
313
src/types/YXmlFragment.js
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @module YXml
|
||||
*/
|
||||
|
||||
import {
|
||||
YXmlEvent,
|
||||
YXmlElement,
|
||||
AbstractType,
|
||||
typeListMap,
|
||||
typeListForEach,
|
||||
typeListInsertGenerics,
|
||||
typeListDelete,
|
||||
typeListToArray,
|
||||
YXmlFragmentRefID,
|
||||
callTypeObservers,
|
||||
transact,
|
||||
Transaction, ItemType, YXmlText, YXmlHook, Snapshot // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
|
||||
/**
|
||||
* Define the elements to which a set of CSS queries apply.
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors}
|
||||
*
|
||||
* @example
|
||||
* query = '.classSelector'
|
||||
* query = 'nodeSelector'
|
||||
* query = '#idSelector'
|
||||
*
|
||||
* @typedef {string} CSS_Selector
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dom filter function.
|
||||
*
|
||||
* @callback domFilter
|
||||
* @param {string} nodeName The nodeName of the element
|
||||
* @param {Map} attributes The map of attributes.
|
||||
* @return {boolean} Whether to include the Dom node in the YXmlElement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
|
||||
* position within them.
|
||||
*
|
||||
* Can be created with {@link YXmlFragment#createTreeWalker}
|
||||
*
|
||||
* @public
|
||||
* @implements {IterableIterator}
|
||||
*/
|
||||
export class YXmlTreeWalker {
|
||||
/**
|
||||
* @param {YXmlFragment | YXmlElement} root
|
||||
* @param {function(AbstractType<any>):boolean} [f]
|
||||
*/
|
||||
constructor (root, f = () => true) {
|
||||
this._filter = f
|
||||
this._root = root
|
||||
/**
|
||||
* @type {ItemType | null}
|
||||
*/
|
||||
// @ts-ignore
|
||||
this._currentNode = root._start
|
||||
this._firstCall = true
|
||||
}
|
||||
|
||||
[Symbol.iterator] () {
|
||||
return this
|
||||
}
|
||||
/**
|
||||
* Get the next node.
|
||||
*
|
||||
* @return {IteratorResult<YXmlElement|YXmlText|YXmlHook>} The next node.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
next () {
|
||||
let n = this._currentNode
|
||||
if (n !== null && (!this._firstCall || n.deleted || !this._filter(n.type))) { // if first call, we check if we can use the first item
|
||||
do {
|
||||
if (!n.deleted && (n.type.constructor === YXmlElement || n.type.constructor === YXmlFragment) && n.type._start !== null) {
|
||||
// walk down in the tree
|
||||
// @ts-ignore
|
||||
n = n.type._start
|
||||
} else {
|
||||
// walk right or up in the tree
|
||||
while (n !== null) {
|
||||
if (n.right !== null) {
|
||||
// @ts-ignore
|
||||
n = n.right
|
||||
break
|
||||
} else if (n.parent === this._root) {
|
||||
n = null
|
||||
} else {
|
||||
n = n.parent._item
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (n !== null && (n.deleted || !this._filter(n.type)))
|
||||
}
|
||||
this._firstCall = false
|
||||
this._currentNode = n
|
||||
if (n === null) {
|
||||
// @ts-ignore return undefined if done=true (the expected result)
|
||||
return { value: undefined, done: true }
|
||||
}
|
||||
// @ts-ignore
|
||||
return { value: n.type, done: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a list of {@link YXmlElement}.and {@link YXmlText} types.
|
||||
* A YxmlFragment is similar to a {@link YXmlElement}, but it does not have a
|
||||
* nodeName and it does not have attributes. Though it can be bound to a DOM
|
||||
* element - in this case the attributes and the nodeName are not shared.
|
||||
*
|
||||
* @public
|
||||
* @extends AbstractType<YXmlEvent>
|
||||
*/
|
||||
export class YXmlFragment extends AbstractType {
|
||||
constructor () {
|
||||
super()
|
||||
/**
|
||||
* @type {Array<any>|null}
|
||||
* @private
|
||||
*/
|
||||
this._prelimContent = []
|
||||
}
|
||||
/**
|
||||
* Create a subtree of childNodes.
|
||||
*
|
||||
* @example
|
||||
* const walker = elem.createTreeWalker(dom => dom.nodeName === 'div')
|
||||
* for (let node in walker) {
|
||||
* // `node` is a div node
|
||||
* nop(node)
|
||||
* }
|
||||
*
|
||||
* @param {function(AbstractType<any>):boolean} filter Function that is called on each child element and
|
||||
* returns a Boolean indicating whether the child
|
||||
* is to be included in the subtree.
|
||||
* @return {YXmlTreeWalker} A subtree and a position within it.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
createTreeWalker (filter) {
|
||||
return new YXmlTreeWalker(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first YXmlElement that matches the query.
|
||||
* Similar to DOM's {@link querySelector}.
|
||||
*
|
||||
* Query support:
|
||||
* - tagname
|
||||
* TODO:
|
||||
* - id
|
||||
* - attribute
|
||||
*
|
||||
* @param {CSS_Selector} query The query on the children.
|
||||
* @return {YXmlElement|YXmlText|YXmlHook|null} The first element that matches the query or null.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
querySelector (query) {
|
||||
query = query.toUpperCase()
|
||||
// @ts-ignore
|
||||
const iterator = new YXmlTreeWalker(this, element => element.nodeName === query)
|
||||
const next = iterator.next()
|
||||
if (next.done) {
|
||||
return null
|
||||
} else {
|
||||
return next.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all YXmlElements that match the query.
|
||||
* Similar to Dom's {@link querySelectorAll}.
|
||||
*
|
||||
* @todo Does not yet support all queries. Currently only query by tagName.
|
||||
*
|
||||
* @param {CSS_Selector} query The query on the children
|
||||
* @return {Array<YXmlElement|YXmlText|YXmlHook|null>} The elements that match this query.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
querySelectorAll (query) {
|
||||
query = query.toUpperCase()
|
||||
// @ts-ignore
|
||||
return Array.from(new YXmlTreeWalker(this, element => element.nodeName === 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.
|
||||
*/
|
||||
_callObserver (transaction, parentSubs) {
|
||||
callTypeObservers(this, transaction, new YXmlEvent(this, parentSubs, transaction))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string representation of all the children of this YXmlFragment.
|
||||
*
|
||||
* @return {string} The string representation of all children.
|
||||
*/
|
||||
toString () {
|
||||
return typeListMap(this, xml => xml.toString()).join('')
|
||||
}
|
||||
|
||||
toJSON () {
|
||||
return this.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Dom Element that mirrors this YXmlElement.
|
||||
*
|
||||
* @param {Document} [_document=document] The document object (you must define
|
||||
* this when calling this method in
|
||||
* nodejs)
|
||||
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
|
||||
* are presented in the DOM
|
||||
* @param {any} [binding] You should not set this property. This is
|
||||
* used if DomBinding wants to create a
|
||||
* association to the created DOM type.
|
||||
* @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
toDOM (_document = document, hooks = {}, binding) {
|
||||
const fragment = _document.createDocumentFragment()
|
||||
if (binding !== undefined) {
|
||||
binding._createAssociation(fragment, this)
|
||||
}
|
||||
typeListForEach(this, xmlType => {
|
||||
fragment.insertBefore(xmlType.toDOM(_document, hooks, binding), null)
|
||||
})
|
||||
return fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts new content at an index.
|
||||
*
|
||||
* @example
|
||||
* // Insert character 'a' at position 0
|
||||
* xml.insert(0, [new Y.XmlText('text')])
|
||||
*
|
||||
* @param {number} index The index to insert content at
|
||||
* @param {Array<YXmlElement|YXmlText>} content The array of content
|
||||
*/
|
||||
insert (index, content) {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeListInsertGenerics(transaction, this, index, content)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore _prelimContent is defined because this is not yet integrated
|
||||
this._prelimContent.splice(index, 0, ...content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes elements starting from an index.
|
||||
*
|
||||
* @param {number} index Index at which to start deleting elements
|
||||
* @param {number} [length=1] The number of elements to remove. Defaults to 1.
|
||||
*/
|
||||
delete (index, length = 1) {
|
||||
if (this.doc !== null) {
|
||||
transact(this.doc, transaction => {
|
||||
typeListDelete(transaction, this, index, length)
|
||||
})
|
||||
} else {
|
||||
// @ts-ignore _prelimContent is defined because this is not yet integrated
|
||||
this._prelimContent.splice(index, length)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Transforms this YArray to a JavaScript Array.
|
||||
*
|
||||
* @return {Array<YXmlElement|YXmlText|YXmlHook>}
|
||||
*/
|
||||
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) {
|
||||
encoding.writeVarUint(encoder, YXmlFragmentRefID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {YXmlFragment}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readYXmlFragment = decoder => new YXmlFragment()
|
||||
@@ -47,7 +47,7 @@ export class YXmlHook extends YMap {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
toDom (_document = document, hooks = {}, binding) {
|
||||
toDOM (_document = document, hooks = {}, binding) {
|
||||
const hook = hooks[this.hookName]
|
||||
let dom
|
||||
if (hook !== undefined) {
|
||||
|
||||
@@ -24,13 +24,52 @@ export class YXmlText extends YText {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
toDom (_document = document, hooks, binding) {
|
||||
toDOM (_document = document, hooks, binding) {
|
||||
const dom = _document.createTextNode(this.toString())
|
||||
if (binding !== undefined) {
|
||||
binding._createAssociation(dom, this)
|
||||
}
|
||||
return dom
|
||||
}
|
||||
|
||||
toString () {
|
||||
// @ts-ignore
|
||||
return this.toDelta().map(delta => {
|
||||
const nestedNodes = []
|
||||
for (let nodeName in delta.attributes) {
|
||||
const attrs = []
|
||||
for (let key in delta.attributes[nodeName]) {
|
||||
attrs.push({ key, value: delta.attributes[nodeName][key] })
|
||||
}
|
||||
// sort attributes to get a unique order
|
||||
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
|
||||
nestedNodes.push({ nodeName, attrs })
|
||||
}
|
||||
// sort node order to get a unique order
|
||||
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
|
||||
// now convert to dom string
|
||||
let str = ''
|
||||
for (let i = 0; i < nestedNodes.length; i++) {
|
||||
const node = nestedNodes[i]
|
||||
str += `<${node.nodeName}`
|
||||
for (let j = 0; j < node.attrs.length; j++) {
|
||||
const attr = node.attrs[i]
|
||||
str += ` ${attr.key}="${attr.value}"`
|
||||
}
|
||||
str += '>'
|
||||
}
|
||||
str += delta.insert
|
||||
for (let i = nestedNodes.length - 1; i >= 0; i--) {
|
||||
str += `</${nestedNodes[i].nodeName}>`
|
||||
}
|
||||
return str
|
||||
}).join('')
|
||||
}
|
||||
|
||||
toJSON () {
|
||||
return this.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
findIndexSS,
|
||||
createID,
|
||||
getState,
|
||||
AbstractItem, StructStore, Transaction, ID // eslint-disable-line
|
||||
AbstractStruct, AbstractItem, StructStore, Transaction, ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as math from 'lib0/math.js'
|
||||
@@ -11,9 +11,6 @@ import * as map from 'lib0/map.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
class DeleteItem {
|
||||
/**
|
||||
* @param {number} clock
|
||||
@@ -37,8 +34,6 @@ class DeleteItem {
|
||||
* - This DeleteSet is send to other clients
|
||||
* - We do not create a DeleteSet when we send a sync message. The DeleteSet message is created directly from StructStore
|
||||
* - We read a DeleteSet as part of a sync/update message. In this case the DeleteSet is already sorted and merged.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export class DeleteSet {
|
||||
constructor () {
|
||||
@@ -50,6 +45,33 @@ export class DeleteSet {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*
|
||||
* @param {DeleteSet} ds
|
||||
* @param {StructStore} store
|
||||
* @param {function(AbstractStruct):void} f
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const iterateDeletedStructs = (ds, store, f) =>
|
||||
ds.clients.forEach((deletes, clientid) => {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (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)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {Array<DeleteItem>} dis
|
||||
* @param {number} clock
|
||||
@@ -244,6 +266,6 @@ export const readDeleteSet = (decoder, transaction, store) => {
|
||||
if (unappliedDS.clients.size > 0) {
|
||||
const unappliedDSEncoder = encoding.createEncoder()
|
||||
writeDeleteSet(unappliedDSEncoder, unappliedDS)
|
||||
store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toBuffer(unappliedDSEncoder)))
|
||||
store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toUint8Array(unappliedDSEncoder)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
YMap,
|
||||
YXmlFragment,
|
||||
transact,
|
||||
Transaction, YEvent // eslint-disable-line
|
||||
AbstractItem, Transaction, YEvent // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import { Observable } from 'lib0/observable.js'
|
||||
@@ -21,13 +21,12 @@ import * as map from 'lib0/map.js'
|
||||
* A Yjs instance handles the state of shared data.
|
||||
* @extends Observable<string>
|
||||
*/
|
||||
export class Y extends Observable {
|
||||
export class Doc extends Observable {
|
||||
/**
|
||||
* @param {Object|undefined} conf configuration
|
||||
*/
|
||||
constructor (conf = {}) {
|
||||
super()
|
||||
// todo: change to clientId
|
||||
this.clientID = random.uint32()
|
||||
/**
|
||||
* @type {Map<string, AbstractType<YEvent>>}
|
||||
@@ -39,6 +38,11 @@ export class Y extends Observable {
|
||||
* @private
|
||||
*/
|
||||
this._transaction = null
|
||||
/**
|
||||
* @type {Array<Transaction>}
|
||||
* @private
|
||||
*/
|
||||
this._transactionCleanups = []
|
||||
}
|
||||
/**
|
||||
* Changes that happen inside of a transaction are bundled. This means that
|
||||
@@ -47,11 +51,12 @@ export class Y extends Observable {
|
||||
* other peers.
|
||||
*
|
||||
* @param {function(Transaction):void} f The function that should be executed as a transaction
|
||||
* @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
transact (f) {
|
||||
transact(this, f)
|
||||
transact (f, origin = null) {
|
||||
transact(this, f, origin)
|
||||
}
|
||||
/**
|
||||
* Define a shared data type.
|
||||
@@ -74,7 +79,7 @@ export class Y extends Observable {
|
||||
* }
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Function} TypeConstructor The constructor of the type definition
|
||||
* @param {Function} TypeConstructor The constructor of the type definition. E.g. Y.Text, Y.Array, Y.Map, ...
|
||||
* @return {AbstractType<any>} The created type. Constructed with TypeConstructor
|
||||
*
|
||||
* @public
|
||||
@@ -89,9 +94,18 @@ export class Y extends Observable {
|
||||
const Constr = type.constructor
|
||||
if (TypeConstructor !== AbstractType && Constr !== TypeConstructor) {
|
||||
if (Constr === AbstractType) {
|
||||
const t = new Constr()
|
||||
// @ts-ignore
|
||||
const t = new TypeConstructor()
|
||||
t._map = type._map
|
||||
type._map.forEach(/** @param {AbstractItem?} n */ n => {
|
||||
for (; n !== null; n = n.left) {
|
||||
n.parent = t
|
||||
}
|
||||
})
|
||||
t._start = type._start
|
||||
for (let n = t._start; n !== null; n = n.right) {
|
||||
n.parent = t
|
||||
}
|
||||
t._length = type._length
|
||||
this.share.set(name, t)
|
||||
t._integrate(this, null)
|
||||
@@ -22,16 +22,6 @@ export class ID {
|
||||
*/
|
||||
this.clock = clock
|
||||
}
|
||||
/**
|
||||
* @deprecated
|
||||
* @todo remove and adapt relative position implementation
|
||||
*/
|
||||
toJSON () {
|
||||
return {
|
||||
client: this.client,
|
||||
clock: this.clock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,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._y.share) {
|
||||
for (let [key, value] of type.doc.share) {
|
||||
if (value === type) {
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/**
|
||||
* @module Cursors
|
||||
*/
|
||||
|
||||
import {
|
||||
getItem,
|
||||
@@ -12,7 +9,8 @@ import {
|
||||
getState,
|
||||
findRootTypeKey,
|
||||
AbstractItem,
|
||||
ID, StructStore, Y, AbstractType // eslint-disable-line
|
||||
ItemType,
|
||||
ID, StructStore, Doc, AbstractType // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -20,29 +18,30 @@ import * as decoding from 'lib0/decoding.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
|
||||
/**
|
||||
* A Cursor is a relative position that is based on the Yjs model. In contrast to an
|
||||
* absolute position (position by index), the Cursor can be
|
||||
* recomputed when remote changes are received. For example:
|
||||
* A relative position is based on the Yjs model and is not affected by document changes.
|
||||
* E.g. If you place a relative position before a certain character, it will always point to this character.
|
||||
* If you place a relative position at the end of a type, it will always point to the end of the type.
|
||||
*
|
||||
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the cursor position.
|
||||
* A numeric position is often unsuited for user selections, because it does not change when content is inserted
|
||||
* before or after.
|
||||
*
|
||||
* A relative cursor position can be obtained with the function
|
||||
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the relative position.
|
||||
*
|
||||
* One of the properties must be defined.
|
||||
*
|
||||
* @example
|
||||
* // Current cursor position is at position 10
|
||||
* const relativePosition = createCursorFromOffset(yText, 10)
|
||||
* const relativePosition = createRelativePositionFromIndex(yText, 10)
|
||||
* // modify yText
|
||||
* yText.insert(0, 'abc')
|
||||
* yText.delete(3, 10)
|
||||
* // Compute the cursor position
|
||||
* const absolutePosition = toAbsolutePosition(y, relativePosition)
|
||||
* const absolutePosition = createAbsolutePositionFromRelativePosition(y, relativePosition)
|
||||
* absolutePosition.type === yText // => true
|
||||
* console.log('cursor location is ' + absolutePosition.offset) // => cursor location is 3
|
||||
* console.log('cursor location is ' + absolutePosition.index) // => cursor location is 3
|
||||
*
|
||||
*/
|
||||
export class Cursor {
|
||||
export class RelativePosition {
|
||||
/**
|
||||
* @param {ID|null} type
|
||||
* @param {string|null} tname
|
||||
@@ -62,35 +61,22 @@ export class Cursor {
|
||||
*/
|
||||
this.item = item
|
||||
}
|
||||
toJSON () {
|
||||
const json = {}
|
||||
if (this.type !== null) {
|
||||
json.type = this.type.toJSON()
|
||||
}
|
||||
if (this.tname !== null) {
|
||||
json.tname = this.tname
|
||||
}
|
||||
if (this.item !== null) {
|
||||
json.item = this.item.toJSON()
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} json
|
||||
* @return {Cursor}
|
||||
* @return {RelativePosition}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createCursorFromJSON = json => new Cursor(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname || null, json.item == null ? null : createID(json.item.client, json.item.clock))
|
||||
export const createRelativePositionFromJSON = json => new RelativePosition(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname || null, json.item == null ? null : createID(json.item.client, json.item.clock))
|
||||
|
||||
export class AbsolutePosition {
|
||||
/**
|
||||
* @param {AbstractType<any>} type
|
||||
* @param {number} offset
|
||||
* @param {number} index
|
||||
*/
|
||||
constructor (type, offset) {
|
||||
constructor (type, index) {
|
||||
/**
|
||||
* @type {AbstractType<any>}
|
||||
*/
|
||||
@@ -98,17 +84,17 @@ export class AbsolutePosition {
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.offset = offset
|
||||
this.index = index
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AbstractType<any>} type
|
||||
* @param {number} offset
|
||||
* @param {number} index
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createAbsolutePosition = (type, offset) => new AbsolutePosition(type, offset)
|
||||
export const createAbsolutePosition = (type, index) => new AbsolutePosition(type, index)
|
||||
|
||||
/**
|
||||
* @param {AbstractType<any>} type
|
||||
@@ -116,7 +102,7 @@ export const createAbsolutePosition = (type, offset) => new AbsolutePosition(typ
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createCursor = (type, item) => {
|
||||
export const createRelativePosition = (type, item) => {
|
||||
let typeid = null
|
||||
let tname = null
|
||||
if (type._item === null) {
|
||||
@@ -124,40 +110,40 @@ export const createCursor = (type, item) => {
|
||||
} else {
|
||||
typeid = type._item.id
|
||||
}
|
||||
return new Cursor(typeid, tname, item)
|
||||
return new RelativePosition(typeid, tname, item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a relativePosition based on a absolute position.
|
||||
*
|
||||
* @param {AbstractType<any>} type The base type (e.g. YText or YArray).
|
||||
* @param {number} offset The absolute position.
|
||||
* @return {Cursor}
|
||||
* @param {number} index The absolute position.
|
||||
* @return {RelativePosition}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createCursorFromTypeOffset = (type, offset) => {
|
||||
export const createRelativePositionFromTypeIndex = (type, index) => {
|
||||
let t = type._start
|
||||
while (t !== null) {
|
||||
if (!t.deleted && t.countable) {
|
||||
if (t.length > offset) {
|
||||
if (t.length > index) {
|
||||
// case 1: found position somewhere in the linked list
|
||||
return createCursor(type, createID(t.id.client, t.id.clock + offset))
|
||||
return createRelativePosition(type, createID(t.id.client, t.id.clock + index))
|
||||
}
|
||||
offset -= t.length
|
||||
index -= t.length
|
||||
}
|
||||
t = t.right
|
||||
}
|
||||
return createCursor(type, null)
|
||||
return createRelativePosition(type, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {Cursor} rpos
|
||||
* @param {RelativePosition} rpos
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const writeCursor = (encoder, rpos) => {
|
||||
export const writeRelativePosition = (encoder, rpos) => {
|
||||
const { type, tname, item } = rpos
|
||||
if (item !== null) {
|
||||
encoding.writeVarUint(encoder, 0)
|
||||
@@ -176,15 +162,23 @@ export const writeCursor = (encoder, rpos) => {
|
||||
return encoder
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelativePosition} rpos
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const encodeRelativePosition = rpos => {
|
||||
const encoder = encoding.createEncoder()
|
||||
writeRelativePosition(encoder, rpos)
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {Y} y
|
||||
* @param {StructStore} store
|
||||
* @return {Cursor|null}
|
||||
* @return {RelativePosition|null}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readCursor = (decoder, y, store) => {
|
||||
export const readRelativePosition = decoder => {
|
||||
let type = null
|
||||
let tname = null
|
||||
let itemID = null
|
||||
@@ -202,23 +196,29 @@ export const readCursor = (decoder, y, store) => {
|
||||
type = readID(decoder)
|
||||
}
|
||||
}
|
||||
return new Cursor(type, tname, itemID)
|
||||
return new RelativePosition(type, tname, itemID)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Cursor} cursor
|
||||
* @param {Y} y
|
||||
* @param {Uint8Array} uint8Array
|
||||
* @return {RelativePosition|null}
|
||||
*/
|
||||
export const decodeRelativePosition = uint8Array => readRelativePosition(decoding.createDecoder(uint8Array))
|
||||
|
||||
/**
|
||||
* @param {RelativePosition} rpos
|
||||
* @param {Doc} doc
|
||||
* @return {AbsolutePosition|null}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const createAbsolutePositionFromCursor = (cursor, y) => {
|
||||
const store = y.store
|
||||
const rightID = cursor.item
|
||||
const typeID = cursor.type
|
||||
const tname = cursor.tname
|
||||
export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
|
||||
const store = doc.store
|
||||
const rightID = rpos.item
|
||||
const typeID = rpos.type
|
||||
const tname = rpos.tname
|
||||
let type = null
|
||||
let offset = 0
|
||||
let index = 0
|
||||
if (rightID !== null) {
|
||||
if (getState(store, rightID.client) <= rightID.clock) {
|
||||
return null
|
||||
@@ -227,40 +227,47 @@ export const createAbsolutePositionFromCursor = (cursor, y) => {
|
||||
if (!(right instanceof AbstractItem)) {
|
||||
return null
|
||||
}
|
||||
offset = right.deleted || !right.countable ? 0 : rightID.clock - right.id.clock
|
||||
index = right.deleted || !right.countable ? 0 : rightID.clock - right.id.clock
|
||||
let n = right.left
|
||||
while (n !== null) {
|
||||
if (!n.deleted && n.countable) {
|
||||
offset += n.length
|
||||
index += n.length
|
||||
}
|
||||
n = n.left
|
||||
}
|
||||
type = right.parent
|
||||
} else {
|
||||
if (tname !== null) {
|
||||
type = y.get(tname)
|
||||
type = doc.get(tname)
|
||||
} else if (typeID !== null) {
|
||||
type = getItemType(store, typeID).type
|
||||
if (getState(store, typeID.client) <= typeID.clock) {
|
||||
// type does not exist yet
|
||||
return null
|
||||
}
|
||||
const struct = getItemType(store, typeID)
|
||||
if (struct instanceof ItemType) {
|
||||
type = struct.type
|
||||
} else {
|
||||
// struct is garbage collected
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
throw error.unexpectedCase()
|
||||
}
|
||||
offset = type._length
|
||||
index = type._length
|
||||
}
|
||||
if (type._item !== null && type._item.deleted) {
|
||||
return null
|
||||
}
|
||||
return createAbsolutePosition(type, offset)
|
||||
return createAbsolutePosition(type, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Cursor|null} a
|
||||
* @param {Cursor|null} b
|
||||
* @param {RelativePosition|null} a
|
||||
* @param {RelativePosition|null} b
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const compareCursors = (a, b) => a === b || (
|
||||
a !== null && b !== null && a.tname === b.tname && (
|
||||
(a.item !== null && b.item !== null && compareIDs(a.item, b.item)) ||
|
||||
(a.type !== null && b.type !== null && compareIDs(a.type, b.type))
|
||||
)
|
||||
export const compareRelativePositions = (a, b) => a === b || (
|
||||
a !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type)
|
||||
)
|
||||
@@ -1,37 +1,35 @@
|
||||
|
||||
import {
|
||||
DeleteSet,
|
||||
isDeleted,
|
||||
AbstractItem // eslint-disable-line
|
||||
DeleteSet, AbstractItem // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
export class Snapshot {
|
||||
/**
|
||||
* @param {DeleteSet} ds delete store
|
||||
* @param {DeleteSet} ds
|
||||
* @param {Map<number,number>} sm state map
|
||||
* @param {Map<number,string>} userMap
|
||||
* @private
|
||||
*/
|
||||
constructor (ds, sm, userMap) {
|
||||
constructor (ds, sm) {
|
||||
/**
|
||||
* @type {DeleteSet}
|
||||
* @private
|
||||
*/
|
||||
this.ds = new DeleteSet()
|
||||
this.ds = ds
|
||||
/**
|
||||
* State Map
|
||||
* @type {Map<number,number>}
|
||||
* @private
|
||||
*/
|
||||
this.sm = sm
|
||||
/**
|
||||
* @type {Map<number,string>}
|
||||
* @private
|
||||
*/
|
||||
this.userMap = userMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DeleteSet} ds
|
||||
* @param {Map<number,number>} sm
|
||||
*/
|
||||
export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
|
||||
|
||||
/**
|
||||
* @param {AbstractItem} item
|
||||
* @param {Snapshot|undefined} snapshot
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
|
||||
import * as math from 'lib0/math.js'
|
||||
import * as error from 'lib0/error.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
|
||||
|
||||
export class StructStore {
|
||||
constructor () {
|
||||
@@ -51,7 +50,7 @@ export class StructStore {
|
||||
* @public
|
||||
* @function
|
||||
*/
|
||||
export const getStates = store => {
|
||||
export const getStateVector = store => {
|
||||
const sm = new Map()
|
||||
store.clients.forEach((structs, client) => {
|
||||
const struct = structs[structs.length - 1]
|
||||
@@ -262,42 +261,3 @@ export const replaceStruct = (store, struct, newStruct) => {
|
||||
const structs = store.clients.get(struct.id.client)
|
||||
structs[findIndexSS(structs, struct.id.clock)] = newStruct
|
||||
}
|
||||
|
||||
/**
|
||||
* Read StateMap from Decoder and return as Map
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {Map<number,number>}
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const readStatesAsMap = decoder => {
|
||||
const ss = new Map()
|
||||
const ssLength = decoding.readVarUint(decoder)
|
||||
for (let i = 0; i < ssLength; i++) {
|
||||
const client = decoding.readVarUint(decoder)
|
||||
const clock = decoding.readVarUint(decoder)
|
||||
ss.set(client, clock)
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
/**
|
||||
* Write StateMap to Encoder
|
||||
*
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {StructStore} store
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const writeStates = (encoder, store) => {
|
||||
encoding.writeVarUint(encoder, store.clients.size)
|
||||
store.clients.forEach((structs, client) => {
|
||||
const id = structs[structs.length - 1].id
|
||||
encoding.writeVarUint(encoder, id.client)
|
||||
encoding.writeVarUint(encoder, id.clock)
|
||||
})
|
||||
return encoder
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
writeDeleteSet,
|
||||
DeleteSet,
|
||||
sortAndMergeDeleteSet,
|
||||
getStates,
|
||||
getStateVector,
|
||||
findIndexSS,
|
||||
callEventHandlerListeners,
|
||||
AbstractItem,
|
||||
ItemDeleted,
|
||||
ID, AbstractType, AbstractStruct, YEvent, Y // eslint-disable-line
|
||||
ID, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -44,14 +43,15 @@ import * as math from 'lib0/math.js'
|
||||
*/
|
||||
export class Transaction {
|
||||
/**
|
||||
* @param {Y} y
|
||||
* @param {Doc} doc
|
||||
* @param {any} origin
|
||||
*/
|
||||
constructor (y) {
|
||||
constructor (doc, origin) {
|
||||
/**
|
||||
* The Yjs instance.
|
||||
* @type {Y}
|
||||
* @type {Doc}
|
||||
*/
|
||||
this.y = y
|
||||
this.doc = doc
|
||||
/**
|
||||
* Describes the set of deleted items by ids
|
||||
* @type {DeleteSet}
|
||||
@@ -61,7 +61,7 @@ export class Transaction {
|
||||
* Holds the state before the transaction started.
|
||||
* @type {Map<Number,Number>}
|
||||
*/
|
||||
this.beforeState = getStates(y.store)
|
||||
this.beforeState = getStateVector(doc.store)
|
||||
/**
|
||||
* Holds the state after the transaction.
|
||||
* @type {Map<Number,Number>}
|
||||
@@ -80,32 +80,30 @@ export class Transaction {
|
||||
* @type {Map<AbstractType<YEvent>,Array<YEvent>>}
|
||||
*/
|
||||
this.changedParentTypes = new Map()
|
||||
/**
|
||||
* @type {encoding.Encoder|null}
|
||||
* @private
|
||||
*/
|
||||
this._updateMessage = null
|
||||
/**
|
||||
* @type {Set<ID>}
|
||||
* @private
|
||||
*/
|
||||
this._mergeStructs = new Set()
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
this.origin = origin
|
||||
}
|
||||
/**
|
||||
* @type {encoding.Encoder|null}
|
||||
* @public
|
||||
*/
|
||||
get updateMessage () {
|
||||
// only create if content was added in transaction (state or ds changed)
|
||||
if (this._updateMessage === null && (this.deleteSet.clients.size > 0 || map.any(this.afterState, (clock, client) => this.beforeState.get(client) !== clock))) {
|
||||
const encoder = encoding.createEncoder()
|
||||
sortAndMergeDeleteSet(this.deleteSet)
|
||||
writeStructsFromTransaction(encoder, this)
|
||||
writeDeleteSet(encoder, this.deleteSet)
|
||||
this._updateMessage = encoder
|
||||
}
|
||||
return this._updateMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Transaction} transaction
|
||||
*/
|
||||
export const computeUpdateMessageFromTransaction = transaction => {
|
||||
if (transaction.deleteSet.clients.size === 0 && !map.any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) {
|
||||
return null
|
||||
}
|
||||
const encoder = encoding.createEncoder()
|
||||
sortAndMergeDeleteSet(transaction.deleteSet)
|
||||
writeStructsFromTransaction(encoder, transaction)
|
||||
writeDeleteSet(encoder, transaction.deleteSet)
|
||||
return encoder
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,130 +113,171 @@ export class Transaction {
|
||||
* @function
|
||||
*/
|
||||
export const nextID = transaction => {
|
||||
const y = transaction.y
|
||||
const y = transaction.doc
|
||||
return createID(y.clientID, getState(y.store, y.clientID))
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the functionality of `y.transact(()=>{..})`
|
||||
*
|
||||
* @param {Y} y
|
||||
* @param {Doc} doc
|
||||
* @param {function(Transaction):void} f
|
||||
* @param {any} [origin]
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const transact = (y, f) => {
|
||||
export const transact = (doc, f, origin = null) => {
|
||||
const transactionCleanups = doc._transactionCleanups
|
||||
let initialCall = false
|
||||
if (y._transaction === null) {
|
||||
if (doc._transaction === null) {
|
||||
initialCall = true
|
||||
y._transaction = new Transaction(y)
|
||||
y.emit('beforeTransaction', [y._transaction, y])
|
||||
doc._transaction = new Transaction(doc, origin)
|
||||
transactionCleanups.push(doc._transaction)
|
||||
doc.emit('beforeTransaction', [doc._transaction, doc])
|
||||
}
|
||||
const transaction = y._transaction
|
||||
try {
|
||||
f(transaction)
|
||||
f(doc._transaction)
|
||||
} finally {
|
||||
if (initialCall) {
|
||||
y._transaction = null
|
||||
y.emit('beforeObserverCalls', [transaction, y])
|
||||
// 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)
|
||||
})
|
||||
// only call afterTransaction listeners if anything changed
|
||||
transaction.afterState = getStates(transaction.y.store)
|
||||
// when all changes & events are processed, emit afterTransaction event
|
||||
// transaction cleanup
|
||||
const store = transaction.y.store
|
||||
const ds = transaction.deleteSet
|
||||
// replace deleted items with ItemDeleted / GC
|
||||
sortAndMergeDeleteSet(ds)
|
||||
y.emit('afterTransaction', [transaction, y])
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
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])
|
||||
/**
|
||||
* @type {Array<AbstractStruct>}
|
||||
* @param {Array<AbstractStruct>} structs
|
||||
* @param {number} pos
|
||||
*/
|
||||
// @ts-ignore
|
||||
const structs = store.clients.get(client)
|
||||
for (let di = 0; di < deleteItems.length; di++) {
|
||||
const deleteItem = deleteItems[di]
|
||||
for (let si = findIndexSS(structs, deleteItem.clock); si < structs.length; si++) {
|
||||
const struct = structs[si]
|
||||
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||
break
|
||||
}
|
||||
if (struct.deleted && struct instanceof AbstractItem && (struct.constructor !== ItemDeleted || (struct.parent._item !== null && struct.parent._item.deleted))) {
|
||||
// check if we can GC
|
||||
struct.gc(transaction, store)
|
||||
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 AbstractItem && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
|
||||
// @ts-ignore we already did a constructor check above
|
||||
right.parent._map.set(right.parentSub, left)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @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 AbstractItem && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
|
||||
// @ts-ignore we already did a constructor check above
|
||||
right.parent._map.set(right.parentSub, left)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
// replace deleted items with ItemDeleted / GC
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
/**
|
||||
* @type {Array<AbstractStruct>}
|
||||
*/
|
||||
// @ts-ignore
|
||||
const structs = 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)
|
||||
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 AbstractItem) {
|
||||
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) {
|
||||
/**
|
||||
* @type {Array<AbstractStruct>}
|
||||
*/
|
||||
// @ts-ignore
|
||||
const structs = 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) {
|
||||
/**
|
||||
* @type {Array<AbstractStruct>}
|
||||
*/
|
||||
// @ts-ignore
|
||||
const structs = 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
|
||||
/**
|
||||
* @type {Array<AbstractStruct>}
|
||||
*/
|
||||
// @ts-ignore
|
||||
const structs = 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge mergeStructs
|
||||
for (const mid of transaction._mergeStructs) {
|
||||
const client = mid.client
|
||||
const clock = mid.clock
|
||||
/**
|
||||
* @type {Array<AbstractStruct>}
|
||||
*/
|
||||
// @ts-ignore
|
||||
const structs = store.clients.get(client)
|
||||
const replacedStructPos = findIndexSS(structs, clock)
|
||||
if (replacedStructPos + 1 < structs.length) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos + 1)
|
||||
}
|
||||
if (replacedStructPos > 0) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos)
|
||||
}
|
||||
}
|
||||
y.emit('afterTransactionCleanup', [transaction, y])
|
||||
doc._transactionCleanups = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +116,10 @@ export class UndoManager {
|
||||
this._undoing = false
|
||||
this._redoing = false
|
||||
this._lastTransactionWasUndo = false
|
||||
const y = scope._y
|
||||
this.y = y
|
||||
const doc = scope.doc
|
||||
this.y = doc
|
||||
let bindingInfos
|
||||
y.on('beforeTransaction', (y, transaction, remote) => {
|
||||
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
|
||||
@@ -130,7 +130,7 @@ export class UndoManager {
|
||||
})
|
||||
}
|
||||
})
|
||||
y.on('afterTransaction', (y, transaction, remote) => {
|
||||
doc.on('afterTransaction', (y, transaction, remote) => {
|
||||
if (!remote && transaction.changedParentTypes.has(scope)) {
|
||||
let reverseOperation = new ReverseOperation(y, transaction, bindingInfos)
|
||||
if (!this._undoing) {
|
||||
@@ -200,3 +200,4 @@ export class UndoManager {
|
||||
return performedRedo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
createID,
|
||||
readID,
|
||||
getState,
|
||||
getStates,
|
||||
getStateVector,
|
||||
readDeleteSet,
|
||||
writeDeleteSet,
|
||||
createDeleteSetFromStructStore,
|
||||
Transaction, AbstractStruct, AbstractStructRef, StructStore, ID // eslint-disable-line
|
||||
Doc, Transaction, AbstractStruct, AbstractStructRef, StructStore, ID // eslint-disable-line
|
||||
} from '../internals.js'
|
||||
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
@@ -104,7 +104,7 @@ export const writeClientsStructs = (encoder, store, _sm) => {
|
||||
sm.set(client, clock)
|
||||
}
|
||||
})
|
||||
getStates(store).forEach((clock, client) => {
|
||||
getStateVector(store).forEach((clock, client) => {
|
||||
if (!_sm.has(client)) {
|
||||
sm.set(client, 0)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ const resumeStructIntegration = (transaction, store) => {
|
||||
structRefs.refs[structRefs.i] = ref
|
||||
stack[stack.length - 1] = r
|
||||
// sort the set because this approach might bring the list out of order
|
||||
structRefs.refs = structRefs.refs.slice(structRefs.i).sort((r1, r2) => r1.id.client - r2.id.client)
|
||||
structRefs.refs = structRefs.refs.slice(structRefs.i).sort((r1, r2) => r1.id.clock - r2.id.clock)
|
||||
structRefs.i = 0
|
||||
continue
|
||||
}
|
||||
@@ -250,7 +250,7 @@ export const tryResumePendingDeleteReaders = (transaction, store) => {
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.y.store, transaction.beforeState)
|
||||
export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.doc.store, transaction.beforeState)
|
||||
|
||||
/**
|
||||
* @param {StructStore} store
|
||||
@@ -297,25 +297,128 @@ export const readStructs = (decoder, transaction, store) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and apply a document update.
|
||||
*
|
||||
* This function has the same effect as `applyUpdate` but accepts an decoder.
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @param {Transaction} transaction
|
||||
* @param {StructStore} store
|
||||
* @param {Doc} ydoc
|
||||
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readModel = (decoder, transaction, store) => {
|
||||
readStructs(decoder, transaction, store)
|
||||
readDeleteSet(decoder, transaction, store)
|
||||
export const readUpdate = (decoder, ydoc, transactionOrigin) =>
|
||||
ydoc.transact(transaction => {
|
||||
readStructs(decoder, transaction, ydoc.store)
|
||||
readDeleteSet(decoder, transaction, ydoc.store)
|
||||
}, transactionOrigin)
|
||||
|
||||
/**
|
||||
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
|
||||
*
|
||||
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
|
||||
*
|
||||
* @param {Doc} ydoc
|
||||
* @param {Uint8Array} update
|
||||
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const applyUpdate = (ydoc, update, transactionOrigin) =>
|
||||
readUpdate(decoding.createDecoder(update), ydoc, transactionOrigin)
|
||||
|
||||
/**
|
||||
* Write all the document as a single update message. If you specify the state of the remote client (`targetStateVector`) it will
|
||||
* only write the operations that are missing.
|
||||
*
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {Doc} doc
|
||||
* @param {Map<number,number>} [targetStateVector] The state of the target that receives the update. Leave empty to write all known structs
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const writeStateAsUpdate = (encoder, doc, targetStateVector = new Map()) => {
|
||||
writeClientsStructs(encoder, doc.store, targetStateVector)
|
||||
writeDeleteSet(encoder, createDeleteSetFromStructStore(doc.store))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {StructStore} store
|
||||
* @param {Map<number,number>} [targetState] The state of the target that receives the update. Leave empty to write all known structs
|
||||
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
|
||||
* only write the operations that are missing.
|
||||
*
|
||||
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
|
||||
* @return {Uint8Array}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const writeModel = (encoder, store, targetState = new Map()) => {
|
||||
writeClientsStructs(encoder, store, targetState)
|
||||
writeDeleteSet(encoder, createDeleteSetFromStructStore(store))
|
||||
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => {
|
||||
const encoder = encoding.createEncoder()
|
||||
const targetStateVector = encodedTargetStateVector == null ? new Map() : decodeStateVector(encodedTargetStateVector)
|
||||
writeStateAsUpdate(encoder, doc, targetStateVector)
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read state vector from Decoder and return as Map
|
||||
*
|
||||
* @param {decoding.Decoder} decoder
|
||||
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const readStateVector = decoder => {
|
||||
const ss = new Map()
|
||||
const ssLength = decoding.readVarUint(decoder)
|
||||
for (let i = 0; i < ssLength; i++) {
|
||||
const client = decoding.readVarUint(decoder)
|
||||
const clock = decoding.readVarUint(decoder)
|
||||
ss.set(client, clock)
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
/**
|
||||
* Read decodedState and return State as Map.
|
||||
*
|
||||
* @param {Uint8Array} decodedState
|
||||
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const decodeStateVector = decodedState => readStateVector(decoding.createDecoder(decodedState))
|
||||
|
||||
/**
|
||||
* Write State Vector to `lib0/encoding.js#Encoder`.
|
||||
*
|
||||
* @param {encoding.Encoder} encoder
|
||||
* @param {Doc} doc
|
||||
*
|
||||
* @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
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode State as Uint8Array.
|
||||
*
|
||||
* @param {Doc} doc
|
||||
* @return {Uint8Array}
|
||||
*
|
||||
* @function
|
||||
*/
|
||||
export const encodeStateVector = doc => {
|
||||
const encoder = encoding.createEncoder()
|
||||
writeDocumentStateVector(encoder, doc)
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
@@ -2,36 +2,20 @@ import * as Y from '../src/index.js'
|
||||
|
||||
import {
|
||||
createDeleteSetFromStructStore,
|
||||
getStates,
|
||||
getStateVector,
|
||||
AbstractItem,
|
||||
DeleteSet, StructStore // eslint-disable-line
|
||||
DeleteSet, StructStore, Doc // eslint-disable-line
|
||||
} from '../src/internals.js'
|
||||
|
||||
import * as t from 'lib0/testing.js'
|
||||
import * as prng from 'lib0/prng.js'
|
||||
import { createMutex } from 'lib0/mutex.js'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as syncProtocol from 'y-protocols/sync.js'
|
||||
|
||||
/**
|
||||
* @param {TestYInstance} y
|
||||
* @param {Y.Transaction} transaction
|
||||
*/
|
||||
const afterTransaction = (y, transaction) => {
|
||||
y.mMux(() => {
|
||||
const m = transaction.updateMessage
|
||||
if (m !== null) {
|
||||
const encoder = encoding.createEncoder()
|
||||
syncProtocol.writeUpdate(encoder, m)
|
||||
broadcastMessage(y, encoding.toBuffer(encoder))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TestYInstance} y // publish message created by `y` to all other online clients
|
||||
* @param {ArrayBuffer} m
|
||||
* @param {Uint8Array} m
|
||||
*/
|
||||
const broadcastMessage = (y, m) => {
|
||||
if (y.tc.onlineConns.has(y)) {
|
||||
@@ -43,7 +27,7 @@ const broadcastMessage = (y, m) => {
|
||||
}
|
||||
}
|
||||
|
||||
export class TestYInstance extends Y.Y {
|
||||
export class TestYInstance extends Doc {
|
||||
/**
|
||||
* @param {TestConnector} testConnector
|
||||
* @param {number} clientID
|
||||
@@ -56,17 +40,18 @@ export class TestYInstance extends Y.Y {
|
||||
*/
|
||||
this.tc = testConnector
|
||||
/**
|
||||
* @type {Map<TestYInstance, Array<ArrayBuffer>>}
|
||||
* @type {Map<TestYInstance, Array<Uint8Array>>}
|
||||
*/
|
||||
this.receiving = new Map()
|
||||
/**
|
||||
* Message mutex
|
||||
* @type {Function}
|
||||
*/
|
||||
this.mMux = createMutex()
|
||||
testConnector.allConns.add(this)
|
||||
// set up observe on local model
|
||||
this.on('afterTransactionCleanup', afterTransaction)
|
||||
this.on('update', /** @param {Uint8Array} update @param {any} origin */ (update, origin) => {
|
||||
if (origin !== testConnector) {
|
||||
const encoder = encoding.createEncoder()
|
||||
syncProtocol.writeUpdate(encoder, update)
|
||||
broadcastMessage(this, encoding.toUint8Array(encoder))
|
||||
}
|
||||
})
|
||||
this.connect()
|
||||
}
|
||||
/**
|
||||
@@ -84,15 +69,15 @@ export class TestYInstance extends Y.Y {
|
||||
if (!this.tc.onlineConns.has(this)) {
|
||||
this.tc.onlineConns.add(this)
|
||||
const encoder = encoding.createEncoder()
|
||||
syncProtocol.writeSyncStep1(encoder, this.store)
|
||||
syncProtocol.writeSyncStep1(encoder, this)
|
||||
// publish SyncStep1
|
||||
broadcastMessage(this, encoding.toBuffer(encoder))
|
||||
broadcastMessage(this, encoding.toUint8Array(encoder))
|
||||
this.tc.onlineConns.forEach(remoteYInstance => {
|
||||
if (remoteYInstance !== this) {
|
||||
// remote instance sends instance to this instance
|
||||
const encoder = encoding.createEncoder()
|
||||
syncProtocol.writeSyncStep1(encoder, remoteYInstance.store)
|
||||
this._receive(encoding.toBuffer(encoder), remoteYInstance)
|
||||
syncProtocol.writeSyncStep1(encoder, remoteYInstance)
|
||||
this._receive(encoding.toUint8Array(encoder), remoteYInstance)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -101,7 +86,7 @@ export class TestYInstance extends Y.Y {
|
||||
* 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.
|
||||
*
|
||||
* @param {ArrayBuffer} message
|
||||
* @param {Uint8Array} message
|
||||
* @param {TestYInstance} remoteClient
|
||||
*/
|
||||
_receive (message, remoteClient) {
|
||||
@@ -165,14 +150,12 @@ export class TestConnector {
|
||||
return this.flushRandomMessage()
|
||||
}
|
||||
const encoder = encoding.createEncoder()
|
||||
receiver.mMux(() => {
|
||||
// console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver))
|
||||
// do not publish data created when this function is executed (could be ss2 or update message)
|
||||
syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver)
|
||||
})
|
||||
// console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver))
|
||||
// do not publish data created when this function is executed (could be ss2 or update message)
|
||||
syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver, receiver.tc)
|
||||
if (encoding.length(encoder) > 0) {
|
||||
// send reply message
|
||||
sender._receive(encoding.toBuffer(encoder), receiver)
|
||||
sender._receive(encoding.toUint8Array(encoder), receiver)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -230,11 +213,13 @@ export class TestConnector {
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {t.TestCase} tc
|
||||
* @param {{users?:number}} conf
|
||||
* @return {{testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}}
|
||||
* @param {InitTestObjectCallback<T>} [initTestObject]
|
||||
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}}
|
||||
*/
|
||||
export const init = (tc, { users = 5 } = {}) => {
|
||||
export const init = (tc, { users = 5 } = {}, initTestObject) => {
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
@@ -254,6 +239,7 @@ export const init = (tc, { users = 5 } = {}) => {
|
||||
result['text' + i] = y.get('text', Y.Text)
|
||||
}
|
||||
testConnector.syncAll()
|
||||
result.testObjects = result.users.map(initTestObject || (() => null))
|
||||
// @ts-ignore
|
||||
return result
|
||||
}
|
||||
@@ -287,7 +273,7 @@ export const compare = users => {
|
||||
// @ts-ignore
|
||||
t.compare(userTextValues[i].map(a => a.insert).join('').length, users[i].getText('text').length)
|
||||
t.compare(userTextValues[i], userTextValues[i + 1])
|
||||
t.compare(getStates(users[i].store), getStates(users[i + 1].store))
|
||||
t.compare(getStateVector(users[i].store), getStateVector(users[i + 1].store))
|
||||
compareDS(createDeleteSetFromStructStore(users[i].store), createDeleteSetFromStructStore(users[i + 1].store))
|
||||
compareStructStores(users[i].store, users[i + 1].store)
|
||||
}
|
||||
@@ -365,15 +351,24 @@ export const compareDS = (ds1, ds2) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
* @param {Array<function(TestYInstance,prng.PRNG):void>} mods
|
||||
* @param {number} iterations
|
||||
* @template T
|
||||
* @callback InitTestObjectCallback
|
||||
* @param {TestYInstance} y
|
||||
* @return {T}
|
||||
*/
|
||||
export const applyRandomTests = (tc, mods, iterations) => {
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {t.TestCase} tc
|
||||
* @param {Array<function(Y.Doc,prng.PRNG,T):void>} mods
|
||||
* @param {number} iterations
|
||||
* @param {InitTestObjectCallback<T>} [initTestObject]
|
||||
*/
|
||||
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
|
||||
const gen = tc.prng
|
||||
const result = init(tc, { users: 5 })
|
||||
const result = init(tc, { users: 5 }, initTestObject || (() => null))
|
||||
const { testConnector, users } = result
|
||||
for (var i = 0; i < iterations; i++) {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (prng.int31(gen, 0, 100) <= 2) {
|
||||
// 2% chance to disconnect/reconnect a random user
|
||||
if (prng.bool(gen)) {
|
||||
@@ -388,9 +383,9 @@ export const applyRandomTests = (tc, mods, iterations) => {
|
||||
// 50% chance to flush a random message
|
||||
testConnector.flushRandomMessage()
|
||||
}
|
||||
let user = prng.oneOf(gen, users)
|
||||
var test = prng.oneOf(gen, mods)
|
||||
test(user, gen)
|
||||
const user = prng.int31(gen, 0, users.length - 1)
|
||||
const test = prng.oneOf(gen, mods)
|
||||
test(users[user], gen, result.testObjects[user])
|
||||
}
|
||||
compare(users)
|
||||
return result
|
||||
|
||||
@@ -144,6 +144,32 @@ export const testInsertAndDeleteEvents = tc => {
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testNestedObserverEvents = tc => {
|
||||
const { array0, users } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {Array<number>}
|
||||
*/
|
||||
const vals = []
|
||||
array0.observe(e => {
|
||||
if (array0.length === 1) {
|
||||
// inserting, will call this observer again
|
||||
// we expect that this observer is called after this event handler finishedn
|
||||
array0.insert(1, [1])
|
||||
vals.push(0)
|
||||
} else {
|
||||
// this should be called the second time an element is inserted (above case)
|
||||
vals.push(1)
|
||||
}
|
||||
})
|
||||
array0.insert(0, [0])
|
||||
t.compareArrays(vals, [0, 1])
|
||||
t.compareArrays(array0.toArray(), [0, 1])
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
@@ -237,7 +263,7 @@ export const testEventTargetIsSetCorrectlyOnRemote = tc => {
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testIteratingArrayContainingTypes = tc => {
|
||||
const y = new Y.Y()
|
||||
const y = new Y.Doc()
|
||||
const arr = y.getArray('arr')
|
||||
const numItems = 10
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
@@ -309,8 +335,8 @@ const arrayTransactions = [
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests20 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 3)
|
||||
export const testRepeatGeneratingYarrayTests4 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 4)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,13 @@ export const testBasicInsertAndDelete = tc => {
|
||||
text0.delete(1, 1)
|
||||
t.assert(text0.toString() === 'b', 'Basic delete works (position 1)')
|
||||
t.compare(delta, [{ retain: 1 }, { delete: 1 }])
|
||||
|
||||
users[0].transact(() => {
|
||||
text0.insert(0, '1')
|
||||
text0.delete(0, 1)
|
||||
})
|
||||
t.compare(delta, [])
|
||||
|
||||
compare(users)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,5 +58,5 @@
|
||||
// "types": ["./src/utils/typedefs.js"]
|
||||
},
|
||||
"include": ["./src/**/*", "./tests/**/*"],
|
||||
"exclude": ["../lib0/**/*", "node_modules"]
|
||||
"exclude": ["../lib0/**/*", "node_modules/**/*", "dist", "dist/**/*.js"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user