Compare commits

..

15 Commits

Author SHA1 Message Date
Kevin Jahns
668e9e8a9b 13.0.0-85 2019-05-25 03:13:54 +02:00
Kevin Jahns
37a6d68543 implement support for boolean values. fixes #151 2019-05-25 03:12:56 +02:00
Kevin Jahns
f893198769 remove examples. fixes #149 2019-05-22 17:32:51 +02:00
Kevin Jahns
d3ee1a0ec2 Add editor support to v13 readme 2019-05-22 01:26:13 +02:00
Kevin Jahns
d6593412a2 13.0.0-84 2019-05-19 21:49:36 +02:00
Kevin Jahns
d31bf36531 use generated esm module by default 2019-05-19 21:48:09 +02:00
Kevin Jahns
a485f550db 13.0.0-83 2019-05-19 20:59:56 +02:00
Kevin Jahns
0610b16227 bump lib0 for webpack compatibility 2019-05-19 20:43:18 +02:00
Kevin Jahns
72e470c5f0 Fix ytext event.delta - items that are synced and deleted
When items are added and deleted in the same transaction, event.delta would recognize them as added (though they are actually deleted). Now it just ignores them.
2019-05-19 20:42:53 +02:00
Kevin Jahns
4d12a02e2f fix offset in state vector 2019-05-16 12:31:53 +02:00
Kevin Jahns
4a7d6f0a2d fix sorting bug that only affects older node versions (probably because old sorting algorithms are not stable) 2019-05-14 15:21:34 +02:00
Kevin Jahns
c80f446b5f README: update provider tutorial 2019-05-12 11:18:43 +02:00
Kevin Jahns
81a529d8dc update *getting started* yjs version 2019-05-07 15:43:09 +02:00
Kevin Jahns
4f0ab78914 13.0.0-82 2019-05-07 13:54:00 +02:00
Kevin Jahns
8c36f67f0b rework and document api 2019-05-07 13:44:23 +02:00
52 changed files with 975 additions and 2435 deletions

View File

@@ -1,13 +1,13 @@
# ![Yjs](https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png)
> 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](#Yjs-CRDT-Algorithm) and exposes its internal 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://github.com/y-js/yjs-demos](https://github.com/y-js/yjs-demos)
* API Docs: [https://yjs.website/](https://yjs.website/)
* Benchmarks: [https://github.com/dmonad/crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks)
# Table of Contents
@@ -16,18 +16,15 @@ Yjs is **network agnostic** (p2p!), supports many existing **rich text editors**
* [Providers](#Providers)
* [Getting Started](#Getting-Started)
* [API](#API)
* [Transaction](#Transaction)
* [Offline Editing](#Offline-Editing)
* [Awareness](#Awareness)
* [Working with Yjs](#Working-with-Yjs)
* [Shared Types](#Shared-Types)
* [Y.Doc](#Y.Doc)
* [Document Updates](#Document-Updates)
* [Relative Positions](#Relative-Positions)
* [Miscellaneous](#Miscellaneous)
* [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)
* [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)
@@ -43,16 +40,18 @@ This repository contains a collection of shared types that can be observed for c
| Name                                                   | Cursors | Binding | Demo |
|---|:-:|---|---|
| [ProseMirror](https://prosemirror.net/) | ✔ | [y-prosemirror](http://github.com/y-js/y-prosemirror) | [link](https://yjs.website/tutorial-prosemirror.html) |
| [Quill](https://quilljs.com/) | | [y-quill](http://github.com/y-js/y-quill) | [link](https://yjs.website/tutorial-quill.html) |
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/y-js/y-codemirror) | [link](https://yjs.website/tutorial-codemirror.html) |
| [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]() |
| [ProseMirror](https://prosemirror.net/) | ✔ | [y-prosemirror](http://github.com/y-js/y-prosemirror) | [demo](https://yjs-demos.now.sh/prosemirror/) |
| [Quill](https://quilljs.com/) | | [y-quill](http://github.com/y-js/y-quill) | [demo](https://yjs-demos.now.sh/quill/) |
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/y-js/y-codemirror) | [demo](https://yjs-demos.now.sh/codemirror/) |
| [Monaco](https://microsoft.github.io/monaco-editor/) | | [y-monaco](http://github.com/y-js/y-monaco) | [demo](https://yjs-demos.now.sh/monaco/) |
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/y-js/y-ace) | [demo](https://yjs-demos.now.sh/ace/) |
| [Textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) | | [y-textarea](http://github.com/y-js/y-textarea) | [demo](https://yjs-demos.now.sh/textarea/) |
| [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) | | [y-dom](http://github.com/y-js/y-dom) | [demo](https://yjs-demos.now.sh/dom/) |
### Providers
Setting up the communication between clients, managing awareness information, and storing shared data for offline usage is quite a hassle. **Providers** manage all that for you and are 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.
<dl>
<dt><a href="http://github.com/y-js/y-websocket">y-websocket</a></dt>
@@ -60,15 +59,15 @@ Setting up the communication between clients, managing awareness information, an
<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] Writes updates effinciently in 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>
<dd>[WIP] Write document updates effinciently to the dat network using <a href="https://github.com/kappa-db/multifeed">multifeed</a>. Each client has an append-only log of CRDT local updates (hypercore). Multifeed manages and sync hypercores and y-dat listens to changes and applies them to the Yjs document.</dd>
</dl>
## Getting Started
Install Yjs and a provider with your favorite package manager. In this section we are going to bind a YText to a DOM textarea.
Install Yjs and a provider with your favorite package manager.
```sh
npm i yjs@13.0.0-80 y-websocket@1.0.0-1 y-textarea
npm i yjs@13.0.0-82 y-websocket@1.0.0-3 y-textarea
```
**Start the y-websocket server**
@@ -77,23 +76,51 @@ npm i yjs@13.0.0-80 y-websocket@1.0.0-1 y-textarea
PORT=1234 node ./node_modules/y-websocket/bin/server.js
```
**Textarea Binding Example**
**Example: Textarea Binding**
This is a complete example on how to create a connection to a [y-websocket](https://github.com/y-js/y-websocket) server instance, sync the shared document to all clients in a *room*, and bind a Y.Text type to a dom textarea. All changes to the textarea are automatically shared with everyone in the same room.
```js
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { TextareaBinding } from 'y-textarea'
const 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 = sharedDocument.getText('my resume')
const ytext = doc.getText('my resume')
// bind to a textarea
// use data bindings to bind types to editors
const binding = new TextareaBinding(ytext, document.querySelector('textarea'))
```
**Example: Observe types**
```js
const yarray = doc.getArray('my-array')
yarray.observe(event => {
console.log('yarray was modified')
})
// every time a local or remote client modifies yarray, the observer is called
yarray.insert(0, ['val']) // => "yarray was modified"
```
**Example: Nest types**
Remember, shared types are just plain old data types. The only limitation is that a shared type must exist only once in the shared document.
```js
const ymap = doc.getMap('map')
const foodArray = new Y.Array()
foodArray.insert(0, ['apple', 'banana'])
ymap.set('food', foodArray)
ymap.get('food') === foodArray // => true
ymap.set('fruit', foodArray) // => Error! foodArray is already defined on the shared document
```
Now you understand how types are defined on a shared document. Next you can jump to the [demo repository](https://github.com/y-js/yjs-demos) or continue reading the API docs.
## API
@@ -102,6 +129,8 @@ Now you understand how types are defined on a shared document. Next you can jump
import * as Y from 'yjs'
```
### Shared Types
<details>
<summary><b>Y.Array</b></summary>
<br>
@@ -110,11 +139,11 @@ import * as Y from 'yjs'
</p>
<pre>const yarray = new Y.Array()</pre>
<dl>
<b><code>insert(index:number, content:Array&lt;object|string|number|Y.Type&gt;)</code></b>
<b><code>insert(index:number, content:Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd>
Insert content at <var>index</var>. Note that content is an array of elements. I.e. <code>array.insert(0, [1]</code> splices the list and inserts 1 at position 0.
</dd>
<b><code>push(Array&lt;Object|Array|string|number|Y.Type&gt;)</code></b>
<b><code>push(Array&lt;Object|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
@@ -124,9 +153,9 @@ import * as Y from 'yjs'
<dd></dd>
<b><code>map(function(T, number, YArray):M):Array&lt;M&gt;</code></b>
<dd></dd>
<b><code>toArray():Array&lt;Object|Array|string|number|Y.Type&gt;</code></b>
<b><code>toArray():Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;</code></b>
<dd>Copies the content of this YArray to a new Array.</dd>
<b><code>toJSON():Array&lt;Object|Array|string|number&gt;</code></b>
<b><code>toJSON():Array&lt;Object|boolean|Array|string|number&gt;</code></b>
<dd>Copies the content of this YArray to a new Array. It transforms all child types to JSON using their <code>toJSON</code> method.</dd>
<b><code>[Symbol.Iterator]</code></b>
<dd>
@@ -159,9 +188,9 @@ import * as Y from 'yjs'
</p>
<pre><code>const ymap = new Y.Map()</code></pre>
<dl>
<b><code>get(key:string):object|string|number|Y.Type</code></b>
<b><code>get(key:string):object|boolean|string|number|Uint8Array|Y.Type</code></b>
<dd></dd>
<b><code>set(key:string, value:object|string|number|Y.Type)</code></b>
<b><code>set(key:string, value:object|boolean|string|number|Uint8Array|Y.Type)</code></b>
<dd></dd>
<b><code>delete(key:string)</code></b>
<dd></dd>
@@ -169,7 +198,7 @@ import * as Y from 'yjs'
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>toJSON():Object&lt;string, Object|Array|string|number&gt;</code></b>
<b><code>toJSON():Object&lt;string, Object|boolean|Array|string|number&gt;</code></b>
<dd>Copies the <code>[key,value]</code> pairs of this YMap to a new Object. It transforms all child types to JSON using their <code>toJSON</code> method.</dd>
<b><code>[Symbol.Iterator]</code></b>
<dd>
@@ -350,38 +379,148 @@ import * as Y from 'yjs'
</dl>
</details>
### Custom Types
### Y.Doc
## Bindings
```js
const doc = new Y.Doc()
```
## Transaction
<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>
## Binary Encoding Protocols
### Sync Protocol
#### 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>
Sync steps
### Document Updates
### Awareness Protocol
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.
### Auth Protocol
**Example: Listen to update events and apply them on remote client**
```js
const doc1 = new Y.Doc()
const doc2 = new Y.Doc()
## Offline Editing
doc1.on('update', update => {
Y.applyUpdate(doc2, update)
})
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:
doc2.on('update', update => {
Y.applyUpdate(doc1, update)
})
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?
// 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?'
```
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.
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.
If you do not care about 1. and 2. you can use `/persistences/indexeddb.js` to mirror the local state to indexeddb.
**Example: Sync two clients by exchanging the complete document structure**
## Working with Yjs
```js
const state1 = Y.encodeStateAsUpdate(ydoc1)
const state2 = Y.encodeStateAsUpdate(ydoc2)
Y.applyUpdate(ydoc1, state2)
Y.applyUpdate(ydoc2, state1)
```
**Example: Sync two clients by computing the differences**
This example shows how to sync two clients with the minimal amount of exchanged data by computing only the differences using the state vector of the remote client. Syncing clients using the state vector requires another roundtrip, but can safe a lot of bandwidth.
```js
const stateVector1 = Y.encodeStateVector(ydoc1)
const stateVector2 = Y.encodeStateVector(ydoc2)
const diff1 = Y.encodeStateAsUpdate(ydoc1, stateVector2)
const diff2 = Y.encodeStateAsUpdate(ydoc2, stateVector1)
Y.applyUpdate(ydoc1, diff2)
Y.applyUpdate(ydoc2, diff1)
```
<dl>
<b><code>Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])</code></b>
<dd>Apply a document update on the shared document. Optionally you can specify <code>transactionOrigin</code> that will be stored on <code>transaction.origin</code> and <code>ydoc.on('update', (update, origin) => ..)</code>.</dd>
<b><code>Y.encodeStateAsUpdate(Y.Doc, [encodedTargetStateVector:Uint8Array]):Uint8Array</code></b>
<dd>Encode the document state as a single update message that can be applied on the remote document. Optionally specify the target state vector to only write the differences to the update message.</dd>
<b><code>Y.encodeStateVector(Y.Doc):Uint8Array</code></b>
<dd>Computes the state vector and encodes it into an Uint8Array.</dd>
</dl>
### Relative Positions
> This API is not stable yet
This feature is intended for managing selections / cursors. When working with other users that manipulate the shared document, you can't trust that an index position (an integer) will stay at the intended location. A *relative position* is fixated to an element in the shared document and is not affected by remote changes. I.e. given the document `"a|c"`, the relative position is attached to `c`. When a remote user modifies the document by inserting a character before the cursor, the cursor will stay attached to the character `c`. `insert(1, 'x')("a|c") = "ax|c"`. When the *relative position* is set to the end of the document, it will stay attached to the end of the document.
**Example: Transform to RelativePosition and back**
```js
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const pos = Y.createAbsolutePositionFromRelativePosition(relPos, doc)
pos.type === ytext // => true
pos.index === 2 // => true
```
**Example: Send relative position to remote client (json)**
```js
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = JSON.stringify(relPos)
// send encodedRelPos to remote client..
const parsedRelPos = JSON.parse(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true
```
**Example: Send relative position to remote client (Uint8Array)**
```js
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = Y.encodeRelativePosition(relPos)
// send encodedRelPos to remote client..
const parsedRelPos = Y.decodeRelativePosition(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true
```
<dl>
<b><code>Y.createRelativePositionFromTypeIndex(Uint8Array|Y.Type, number)</code></b>
<dd></dd>
<b><code>Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc)</code></b>
<dd></dd>
<b><code>Y.encodeRelativePosition(RelativePosition):Uint8Array</code></b>
<dd></dd>
<b><code>Y.decodeRelativePosition(Uint8Array):RelativePosition</code></b>
<dd></dd>
</dl>
## Miscellaneous
### Typescript Declarations
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
{
@@ -395,10 +534,26 @@ Until [this](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, the
## Yjs CRDT Algorithm
*Conflict-free replicated data types* (CRDT) for collaborative editing are an alternative approach to *operational transformation* (OT). A very simple differenciation between the two approaches is that OT attempts to transform index positions to ensure convergence (all clients end up with the same content), while CRDTs use mathematical models that usually do not involve index transformations, like linked lists. OT is currently the de-facto standard for shared editing on text. OT approaches that support shared editing without a central source of truth (a central server) require too much bookkeeping to be viable in practice. CRDTs are better suited for distributed systems, provide additional guarantees that the document can be synced with remote clients, and do not require a central source of truth.
Yjs implements a modified version of the algorithm described in [this paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types). I will eventually publish a paper that describes why this approach works so well in practice. Note: Since operations make up the document structure, we prefer the term *struct* now.
CRDTs suitable for shared text editing suffer from the fact that they only grow in size. There are CRDTs that do not grow in size, but they do not have the characteristics that are benificial for shared text editing (like intention preservation). Yjs implements many improvements to the original algorithm that diminish the trade-off that the document only grows in size. We can't garbage collect deleted structs (tombstones) while ensuring a unique order of the structs. But we can 1. merge preceeding structs into a single struct to reduce the amount of meta information, 2. we can delete content from the struct if it is deleted, and 3. we can garbage collect tombstones if we don't care about the order of the structs anymore (e.g. if the parent was deleted).
**Examples:**
1. If a user inserts elements in sequence, the struct will be merged into a single struct. E.g. `array.insert(0, ['a']), array.insert(0, ['b']);` is first represented as two structs (`[{id: {client, clock: 0}, content: 'a'}, {id: {client, clock: 1}, content: 'b'}`) and then merged into a single struct: `[{id: {client, clock: 0}, content: 'ab'}]`.
2. When a struct that contains content (e.g. `ItemString`) is deleted, the struct will be replaced with an `ItemDeleted` that does not contain content anymore.
3. When a type is deleted, all child elements are transformed to `GC` structs. A `GC` struct only denotes the existence of a struct and that it is deleted. `GC` structs can always be merged with other `GC` structs if the id's are adjacent.
Especially when working on structured content (e.g. shared editing on ProseMirror), these improvements yield very good results when [benchmarking](https://github.com/dmonad/crdt-benchmarks) random document edits. In practice they show even better results, because users usually edit text in sequence, resulting in structs that can easily be merged. The benchmarks show that even in the worst case scenario that a user edits text from right to left, Yjs achieves good performance even for huge documents.
#### State Vector
Yjs has the ability to exchange only the differences when syncing two clients. We use lamport timestamps to identify structs and to track in which order a client created them. Each struct has an `struct.id = { client: number, clock: number}` that uniquely identifies a struct. We define the next expected `clock` by each client as the *state vector*. This data structure is similar to the [version vectors](https://en.wikipedia.org/wiki/Version_vector) data structure. But we use state vectors only to describe the state of the local document, so we can compute the missing struct of the remote client. We do not use it to track causality.
## License and Author
Yjs and all related projects are [**MIT licensed**](./LICENSE).
Yjs is based on the research I did as a student at the [RWTH i5](http://dbis.rwth-aachen.de/). Now I am working on Yjs in my spare time.
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.
Support me on [Patreon](https://www.patreon.com/dmonad) to fund this project or hire [me](https://github.com/dmonad) for professional support.
Fund this project by donating on [Patreon](https://www.patreon.com/dmonad) or hiring [me](https://github.com/dmonad) for professional support.

1
examples/.gitignore vendored
View File

@@ -1 +0,0 @@
build

View File

@@ -1,70 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs CodeMirror Example</title>
<link rel=stylesheet href="https://codemirror.net/lib/codemirror.css">
<style>
#container {
border: grey;
border-style: solid;
border-width: thin;
}
</style>
</head>
<body>
<p>This example shows how to bind a YText type to <a href="https://codemirror.net/">CodeMirror</a> editor.</p>
<p>The content of this editor is shared with every client who visits this domain.</p>
<div class="code-html">
<style>
.remote-caret {
position: absolute;
border-left: black;
border-left-style: solid;
border-left-width: 2px;
height: 1em;
}
.remote-caret > div {
position: relative;
top: -1.05em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-family: serif;
font-style: normal;
font-weight: normal;
line-height: normal;
user-select: none;
color: white;
padding-left: 2px;
padding-right: 2px;
}
</style>
<div id="container"></div>
</div>
<!-- The actual source file for the following code is found in ./codemirror.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/codemirror.js" type="module">
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { CodeMirrorBinding } from 'yjs/bindings/codemirror.js'
import * as conf from './exampleConfig.js'
import CodeMirror from 'codemirror'
import 'codemirror/mode/javascript/javascript.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('codemirror')
const ytext = ydocument.define('codemirror', Y.Text)
const editor = new CodeMirror(document.querySelector('#container'), {
mode: 'javascript',
lineNumbers: true
})
const binding = new CodeMirrorBinding(ytext, editor)
window.codemirrorExample = {
binding, editor, ytext, ydocument
}
</script>
</body>
</html>

View File

@@ -1,22 +0,0 @@
import { WebsocketProvider } from 'y-websocket'
import { CodeMirrorBinding } from 'y-codemirror'
import * as conf from './exampleConfig.js'
import CodeMirror from 'codemirror'
import 'codemirror/mode/javascript/javascript.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('codemirror')
const ytext = ydocument.getText('codemirror')
const editor = new CodeMirror(document.querySelector('#container'), {
mode: 'javascript',
lineNumbers: true
})
const binding = new CodeMirrorBinding(ytext, editor)
window.codemirrorExample = {
binding, editor, ytext, ydocument
}

View File

@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs Prosemirror Example</title>
<link rel=stylesheet href="https://prosemirror.net/css/editor.css">
<style>
#content {
min-height: 500px;
}
</style>
</head>
<body>
<p>This example shows how to bind a YXmlFragment type to an arbitrary DOM element. We set the DOM element to contenteditable so it basically behaves like a very powerful rich-text editor.</p>
<p>The content of this editor is shared with every client who visits this domain.</p>
<hr>
<div class="code-html">
<div id="content" contenteditable=""></div>
</div>
<!-- The actual source file for the following code is found in ./dom.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/dom.js" type="module">
import * as Y from 'yjs/index.js'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { DomBinding } from 'yjs/bindings/dom.js'
import * as conf from './exampleConfig.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('dom')
const type = ydocument.define('xml', Y.XmlFragment)
const binding = new DomBinding(type, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
window.example = {
provider, ydocument, type, binding
}
</script>
</body>
</html>

View File

@@ -1,13 +0,0 @@
import * as Y from '../src/index.js'
import { WebsocketProvider } from 'y-websocket'
import { DomBinding } from 'y-dom'
import * as conf from './exampleConfig.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('dom')
const type = ydocument.define('xml', Y.XmlFragment)
const binding = new DomBinding(type, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
window.example = {
provider, ydocument, type, binding
}

View File

@@ -1,9 +0,0 @@
/* eslint-env browser */
const isDeployed = location.hostname === 'yjs.website'
if (!isDeployed) {
console.log('%cYjs: Start your local websocket server by running %c`npm run websocket-server`', 'color:blue', 'color: grey; font-weight: bold')
}
export const serverAddress = isDeployed ? 'wss://api.yjs.website' : 'ws://localhost:1234'

View File

@@ -1,17 +0,0 @@
{
"codemirror": {
"title": "CodeMirror Binding"
},
"prosemirror": {
"title": "ProseMirror Binding"
},
"textarea": {
"title": "Textarea Binding"
},
"quill": {
"title": "Quill Binding"
},
"dom": {
"title": "Dom Binding"
}
}

View File

@@ -1,159 +0,0 @@
import { Plugin } from 'prosemirror-state'
import crel from 'crel'
import * as Y from '../src/index.js'
import { prosemirrorPluginKey } from 'y-prosemirror'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as historyProtocol from 'y-protocols/history.js'
const niceColors = ['#3cb44b', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#008080', '#9a6324', '#800000', '#808000', '#000075', '#808080']
const createUserCSS = (userid, username, color = 'rgb(250, 129, 0)', color2 = 'rgba(250, 129, 0, .41)') => `
[ychange_state][ychange_user="${userid}"]:hover::before {
content: "${username}" !important;
background-color: ${color} !important;
}
[ychange_state="added"][ychange_user="${userid}"] {
background-color: ${color2} !important;
}
[ychange_state="removed"][ychange_user="${userid}"] {
color: ${color} !important;
}
`
export const noteHistoryPlugin = new Plugin({
state: {
init (initargs, state) {
return new NoteHistoryPlugin()
},
apply (tr, pluginState) {
return pluginState
}
},
view (editorView) {
const hstate = noteHistoryPlugin.getState(editorView.state)
hstate.init(editorView)
return {
destroy: hstate.destroy.bind(hstate)
}
}
})
const createWrapper = () => {
const wrapper = crel('div', { style: 'display: flex;' })
const historyContainer = crel('div', { style: 'align-self: baseline; flex-basis: 250px;', class: 'shared-history' })
wrapper.insertBefore(historyContainer, null)
const userStyleContainer = crel('style')
wrapper.insertBefore(userStyleContainer, null)
return { wrapper, historyContainer, userStyleContainer }
}
class NoteHistoryPlugin {
init (editorView) {
this.editorView = editorView
const { historyContainer, wrapper, userStyleContainer } = createWrapper()
this.userStyleContainer = userStyleContainer
this.wrapper = wrapper
this.historyContainer = historyContainer
const n = editorView.dom.parentNode.parentNode
n.parentNode.replaceChild(this.wrapper, n)
n.style['flex-grow'] = '1'
wrapper.insertBefore(n, this.wrapper.firstChild)
this.render()
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array)
history.observe(this.render.bind(this))
}
destroy () {
this.wrapper.parentNode.replaceChild(this.wrapper.firstChild, this.wrapper)
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array)
history.unobserve(this.render)
}
render () {
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array).toArray()
const fragment = document.createDocumentFragment()
const snapshotBtn = crel('button', { type: 'button' }, ['snapshot'])
fragment.insertBefore(snapshotBtn, null)
let _prevSnap = null // empty
snapshotBtn.addEventListener('click', () => {
const awareness = y.getAwarenessInfo()
const userMap = new Map()
const aw = y.getLocalAwarenessInfo()
userMap.set(y.userID, aw.name || 'unknown')
awareness.forEach((a, userID) => {
userMap.set(userID, a.name || 'Unknown')
})
this.snapshot(userMap)
})
history.forEach(buf => {
const decoder = decoding.createDecoder(buf)
const snapshot = historyProtocol.readHistorySnapshot(decoder)
const date = new Date(decoding.readUint32(decoder) * 1000)
const restoreBtn = crel('button', { type: 'button' }, ['restore'])
const a = crel('a', [
'• ' + date.toUTCString(), restoreBtn
])
const el = crel('div', [ a ])
let prevSnapshot = _prevSnap // rebind to new variable
restoreBtn.addEventListener('click', event => {
if (prevSnapshot === null) {
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
}
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot, restore: true }))
event.stopPropagation()
})
a.addEventListener('click', () => {
console.log('setting snapshot')
if (prevSnapshot === null) {
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
}
this.renderSnapshot(snapshot, prevSnapshot)
})
fragment.insertBefore(el, null)
_prevSnap = snapshot
})
this.historyContainer.innerHTML = ''
this.historyContainer.insertBefore(fragment, null)
}
renderSnapshot (snapshot, prevSnapshot) {
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot }))
/**
* @type {Array<string|null>}
*/
let colors = niceColors.slice()
let style = ''
snapshot.userMap.forEach((name, userid) => {
/**
* @type {any}
*/
const randInt = name.split('').map(s => s.charCodeAt(0)).reduce((a, b) => a + b)
let color = null
let i = 0
for (; i < colors.length && color === null; i++) {
color = colors[(randInt + i) % colors.length]
}
if (color === null) {
colors = niceColors.slice()
i = 0
color = colors[randInt % colors.length]
}
colors[randInt % colors.length] = null
style += createUserCSS(userid, name, color, color + '69')
})
this.userStyleContainer.innerHTML = style
}
/**
* @param {Map<number, string>} [updatedUserMap] Maps from userid (yjs model) to account name (e.g. mail address)
*/
snapshot (updatedUserMap = new Map()) {
const y = prosemirrorPluginKey.getState(this.editorView.state).y
const history = y.define('history', Y.Array)
const encoder = encoding.createEncoder()
historyProtocol.writeHistorySnapshot(encoder, y, updatedUserMap)
encoding.writeUint32(encoder, Math.floor(Date.now() / 1000))
history.push([encoding.toBuffer(encoder)])
}
}

View File

@@ -1,197 +0,0 @@
import { Schema } from 'prosemirror-model'
const brDOM = ['br']
const calcYchangeDomAttrs = (attrs, domAttrs = {}) => {
domAttrs = Object.assign({}, domAttrs)
if (attrs.ychange !== null) {
domAttrs.ychange_user = attrs.ychange.user
domAttrs.ychange_state = attrs.ychange.state
}
return domAttrs
}
// :: Object
// [Specs](#model.NodeSpec) for the nodes defined in this schema.
export const nodes = {
// :: NodeSpec The top level document node.
doc: {
content: 'block+'
},
// :: NodeSpec A plain paragraph textblock. Represented in the DOM
// as a `<p>` element.
paragraph: {
attrs: { ychange: { default: null } },
content: 'inline*',
group: 'block',
parseDOM: [{ tag: 'p' }],
toDOM (node) { return ['p', calcYchangeDomAttrs(node.attrs), 0] }
},
// :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks.
blockquote: {
attrs: { ychange: { default: null } },
content: 'block+',
group: 'block',
defining: true,
parseDOM: [{ tag: 'blockquote' }],
toDOM (node) { return ['blockquote', calcYchangeDomAttrs(node.attrs), 0] }
},
// :: NodeSpec A horizontal rule (`<hr>`).
horizontal_rule: {
attrs: { ychange: { default: null } },
group: 'block',
parseDOM: [{ tag: 'hr' }],
toDOM (node) {
return ['hr', calcYchangeDomAttrs(node.attrs)]
}
},
// :: NodeSpec A heading textblock, with a `level` attribute that
// should hold the number 1 to 6. Parsed and serialized as `<h1>` to
// `<h6>` elements.
heading: {
attrs: {
level: { default: 1 },
ychange: { default: null }
},
content: 'inline*',
group: 'block',
defining: true,
parseDOM: [{ tag: 'h1', attrs: { level: 1 } },
{ tag: 'h2', attrs: { level: 2 } },
{ tag: 'h3', attrs: { level: 3 } },
{ tag: 'h4', attrs: { level: 4 } },
{ tag: 'h5', attrs: { level: 5 } },
{ tag: 'h6', attrs: { level: 6 } }],
toDOM (node) { return ['h' + node.attrs.level, calcYchangeDomAttrs(node.attrs), 0] }
},
// :: NodeSpec A code listing. Disallows marks or non-text inline
// nodes by default. Represented as a `<pre>` element with a
// `<code>` element inside of it.
code_block: {
attrs: { ychange: { default: null } },
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
toDOM (node) { return ['pre', calcYchangeDomAttrs(node.attrs), ['code', 0]] }
},
// :: NodeSpec The text node.
text: {
group: 'inline'
},
// :: NodeSpec An inline image (`<img>`) node. Supports `src`,
// `alt`, and `href` attributes. The latter two default to the empty
// string.
image: {
inline: true,
attrs: {
ychange: { default: null },
src: {},
alt: { default: null },
title: { default: null }
},
group: 'inline',
draggable: true,
parseDOM: [{ tag: 'img[src]',
getAttrs (dom) {
return {
src: dom.getAttribute('src'),
title: dom.getAttribute('title'),
alt: dom.getAttribute('alt')
}
} }],
toDOM (node) {
const domAttrs = {
src: node.attrs.src,
title: node.attrs.title,
alt: node.attrs.alt
}
return ['img', calcYchangeDomAttrs(node.attrs, domAttrs)]
}
},
// :: NodeSpec A hard line break, represented in the DOM as `<br>`.
hard_break: {
inline: true,
group: 'inline',
selectable: false,
parseDOM: [{ tag: 'br' }],
toDOM () { return brDOM }
}
}
const emDOM = ['em', 0]; const strongDOM = ['strong', 0]; const codeDOM = ['code', 0]
// :: Object [Specs](#model.MarkSpec) for the marks in the schema.
export const marks = {
// :: MarkSpec A link. Has `href` and `title` attributes. `title`
// defaults to the empty string. Rendered and parsed as an `<a>`
// element.
link: {
attrs: {
href: {},
title: { default: null }
},
inclusive: false,
parseDOM: [{ tag: 'a[href]',
getAttrs (dom) {
return { href: dom.getAttribute('href'), title: dom.getAttribute('title') }
} }],
toDOM (node) { return ['a', node.attrs, 0] }
},
// :: MarkSpec An emphasis mark. Rendered as an `<em>` element.
// Has parse rules that also match `<i>` and `font-style: italic`.
em: {
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }],
toDOM () { return emDOM }
},
// :: MarkSpec A strong mark. Rendered as `<strong>`, parse rules
// also match `<b>` and `font-weight: bold`.
strong: {
parseDOM: [{ tag: 'strong' },
// This works around a Google Docs misbehavior where
// pasted content will be inexplicably wrapped in `<b>`
// tags with a font-weight normal.
{ tag: 'b', getAttrs: node => node.style.fontWeight !== 'normal' && null },
{ style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null }],
toDOM () { return strongDOM }
},
// :: MarkSpec Code font mark. Represented as a `<code>` element.
code: {
parseDOM: [{ tag: 'code' }],
toDOM () { return codeDOM }
},
ychange: {
attrs: {
user: { default: null },
state: { default: null }
},
inclusive: false,
parseDOM: [{ tag: 'ychange' }],
toDOM (node) {
return ['ychange', { ychange_user: node.attrs.user, ychange_state: node.attrs.state }, 0]
}
}
}
// :: Schema
// This schema rougly corresponds to the document schema used by
// [CommonMark](http://commonmark.org/), minus the list elements,
// which are defined in the [`prosemirror-schema-list`](#schema-list)
// module.
//
// To reuse elements from this schema, extend or read from its
// `spec.nodes` and `spec.marks` [properties](#model.Schema.spec).
export const schema = new Schema({ nodes, marks })

View File

@@ -1,330 +0,0 @@
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection { background: transparent; }
.ProseMirror-hideselection *::-moz-selection { background: transparent; }
.ProseMirror-hideselection { caret-color: transparent; }
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: -32px;
right: -2px; top: -2px; bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
.ProseMirror-textblock-dropdown {
min-width: 3em;
}
.ProseMirror-menu {
margin: 0 -4px;
line-height: 1;
}
.ProseMirror-tooltip .ProseMirror-menu {
width: -webkit-fit-content;
width: fit-content;
white-space: pre;
}
.ProseMirror-menuitem {
margin-right: 3px;
display: inline-block;
}
.ProseMirror-menuseparator {
border-right: 1px solid #ddd;
margin-right: 3px;
}
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu {
font-size: 90%;
white-space: nowrap;
}
.ProseMirror-menu-dropdown {
vertical-align: 1px;
cursor: pointer;
position: relative;
padding-right: 15px;
}
.ProseMirror-menu-dropdown-wrap {
padding: 1px 0 1px 4px;
display: inline-block;
position: relative;
}
.ProseMirror-menu-dropdown:after {
content: "";
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
opacity: .6;
position: absolute;
right: 4px;
top: calc(50% - 2px);
}
.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu {
position: absolute;
background: white;
color: #666;
border: 1px solid #aaa;
padding: 2px;
}
.ProseMirror-menu-dropdown-menu {
z-index: 15;
min-width: 6em;
}
.ProseMirror-menu-dropdown-item {
cursor: pointer;
padding: 2px 8px 2px 4px;
}
.ProseMirror-menu-dropdown-item:hover {
background: #f2f2f2;
}
.ProseMirror-menu-submenu-wrap {
position: relative;
margin-right: -4px;
}
.ProseMirror-menu-submenu-label:after {
content: "";
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 4px solid currentColor;
opacity: .6;
position: absolute;
right: 4px;
top: calc(50% - 4px);
}
.ProseMirror-menu-submenu {
display: none;
min-width: 4em;
left: 100%;
top: -3px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-disabled {
opacity: .3;
}
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
display: block;
}
.ProseMirror-menubar {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
position: relative;
min-height: 1em;
color: #666;
padding: 1px 6px;
top: 0; left: 0; right: 0;
border-bottom: 1px solid silver;
background: white;
z-index: 10;
-moz-box-sizing: border-box;
box-sizing: border-box;
overflow: visible;
}
.ProseMirror-icon {
display: inline-block;
line-height: .8;
vertical-align: -2px; /* Compensate for padding */
padding: 2px 8px;
cursor: pointer;
}
.ProseMirror-menu-disabled.ProseMirror-icon {
cursor: default;
}
.ProseMirror-icon svg {
fill: currentColor;
height: 1em;
}
.ProseMirror-icon span {
vertical-align: text-top;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
/* Add space around the hr to make clicking it easier */
.ProseMirror-example-setup-style hr {
padding: 2px 10px;
border: none;
margin: 1em 0;
}
.ProseMirror-example-setup-style hr:after {
content: "";
display: block;
height: 1px;
background-color: silver;
line-height: 2px;
}
.ProseMirror ul, .ProseMirror ol {
padding-left: 30px;
}
.ProseMirror blockquote {
padding-left: 1em;
border-left: 3px solid #eee;
margin-left: 0; margin-right: 0;
}
.ProseMirror-example-setup-style img {
cursor: default;
}
.ProseMirror-prompt {
background: white;
padding: 5px 10px 5px 15px;
border: 1px solid silver;
position: fixed;
border-radius: 3px;
z-index: 11;
box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2);
}
.ProseMirror-prompt h5 {
margin: 0;
font-weight: normal;
font-size: 100%;
color: #444;
}
.ProseMirror-prompt input[type="text"],
.ProseMirror-prompt textarea {
background: #eee;
border: none;
outline: none;
}
.ProseMirror-prompt input[type="text"] {
padding: 0 4px;
}
.ProseMirror-prompt-close {
position: absolute;
left: 2px; top: 1px;
color: #666;
border: none; background: transparent; padding: 0;
}
.ProseMirror-prompt-close:after {
content: "✕";
font-size: 12px;
}
.ProseMirror-invalid {
background: #ffc;
border: 1px solid #cc7;
border-radius: 4px;
padding: 5px 10px;
position: absolute;
min-width: 10em;
}
.ProseMirror-prompt-buttons {
margin-top: 5px;
display: none;
}
#editor, .editor {
background: white;
color: black;
background-clip: padding-box;
border-radius: 4px;
border: 2px solid rgba(0, 0, 0, 0.2);
padding: 5px 0;
margin-bottom: 23px;
}
.ProseMirror p:first-child,
.ProseMirror h1:first-child,
.ProseMirror h2:first-child,
.ProseMirror h3:first-child,
.ProseMirror h4:first-child,
.ProseMirror h5:first-child,
.ProseMirror h6:first-child {
margin-top: 10px;
}
.ProseMirror {
padding: 4px 8px 4px 14px;
line-height: 1.2;
outline: none;
}
.ProseMirror p { margin-bottom: 1em }

View File

@@ -1,123 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs Prosemirror Example</title>
<link rel=stylesheet href="./prosemirror.css">
<style>
placeholder {
display: inline;
border: 1px solid #ccc;
color: #ccc;
}
placeholder:after {
content: "☁";
font-size: 200%;
line-height: 0.1;
font-weight: bold;
}
.ProseMirror img { max-width: 100px }
/* this is a rough fix for the first cursor position when the first paragraph is empty */
.ProseMirror > .ProseMirror-yjs-cursor:first-child {
margin-top: 16px;
}
.ProseMirror p:first-child, .ProseMirror h1:first-child, .ProseMirror h2:first-child, .ProseMirror h3:first-child, .ProseMirror h4:first-child, .ProseMirror h5:first-child, .ProseMirror h6:first-child {
margin-top: 16px
}
.ProseMirror-yjs-cursor {
position: absolute;
border-left: black;
border-left-style: solid;
border-left-width: 2px;
border-color: orange;
height: 1em;
word-break: normal;
}
.ProseMirror-yjs-cursor > div {
position: relative;
top: -1.05em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-family: serif;
font-style: normal;
font-weight: normal;
line-height: normal;
user-select: none;
color: white;
padding-left: 2px;
padding-right: 2px;
}
[ychange_state] {
position: relative;
}
[ychange_state]:hover::before {
content: attr(ychange_user);
background-color: #fa8100;
position: absolute;
top: -14px;
right: 0;
font-size: 12px;
padding: 0 2px;
border-radius: 3px 3px 0 0;
color: #fdfdfe;
user-select: none;
word-break: normal;
}
*[ychange_state='added'] {
background-color: #fa810069;
}
ychange[ychange_state='removed'] {
color: rgb(250, 129, 0);
text-decoration: line-through;
}
*:not(ychange)[ychange_state='removed'] {
background-color: #ff9494c9;
text-decoration: line-through;
}
img[ychange_state='removed'] {
padding: 2px;
}
.y-connect-btn {
position: absolute;
top: 20px;
right: 20px;
}
</style>
</head>
<body>
<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>
<div style="display: none" id="content"></div>
</div>
<!-- The actual source file for the following code is found in ./prosemirror.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/prosemirror.js" type="module">
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { prosemirrorPlugin, cursorPlugin } from 'yjs/bindings/prosemirror'
import * as conf from './exampleConfig.js'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { DOMParser } from 'prosemirror-model'
import { schema } from 'prosemirror-schema-basic'
import { exampleSetup } from 'prosemirror-example-setup'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('prosemirror')
const type = ydocument.define('prosemirror', Y.XmlFragment)
const prosemirrorView = new EditorView(document.querySelector('#editor'), {
state: EditorState.create({
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
plugins: exampleSetup({schema}).concat([prosemirrorPlugin(type), cursorPlugin])
})
})
window.example = { provider, ydocument, type, prosemirrorView }
</script>
</body>
</html>

View File

@@ -1,36 +0,0 @@
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { prosemirrorPlugin, cursorPlugin } from 'y-prosemirror'
import * as conf from './exampleConfig.js'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { DOMParser } from 'prosemirror-model'
import { schema } from './prosemirror-schema.js'
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.get('prosemirror', Y.XmlFragment)
const prosemirrorView = new EditorView(document.querySelector('#editor'), {
state: EditorState.create({
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
plugins: exampleSetup({ schema }).concat([prosemirrorPlugin(type), cursorPlugin])
})
})
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 }

View File

@@ -1,51 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs Quill Example</title>
<link rel="stylesheet" href="https://cdn.quilljs.com/1.3.6/quill.snow.css">
</head>
<body>
<p>This example shows how to bind a YText type to <a href="https://quilljs.com">Quill</a> editor.</p>
<p>The content of this editor is shared with every client who visits this domain.</p>
<div class="code-html">
<div id="quill-container">
<div id="quill">
</div>
</div>
</div>
<!-- The actual source file for the following code is found in ./quill.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/quill.js" type="module">
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { QuillBinding } from 'yjs/bindings/quill.js'
import * as conf from './exampleConfig.js'
import Quill from 'quill'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('quill')
const ytext = ydocument.define('quill', Y.Text)
const quill = new Quill('#quill-container', {
modules: {
toolbar: [
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }]
]
},
placeholder: 'Compose an epic...',
theme: 'snow' // or 'bubble'
})
window.quillBinding = new QuillBinding(ytext, quill)
</script>
</body>
</html>

View File

@@ -1,30 +0,0 @@
import * as Y from '../src/index.js'
import { WebsocketProvider } from 'y-websocket'
import { QuillBinding } from 'y-quill'
import * as conf from './exampleConfig.js'
import Quill from 'quill'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('quill')
const ytext = ydocument.define('quill', Y.Text)
const quill = new Quill('#quill-container', {
modules: {
toolbar: [
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block'],
[{ color: [] }, { background: [] }], // Snow theme fills in values
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['link', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }]
]
},
placeholder: 'Compose an epic...',
theme: 'snow' // or 'bubble'
})
window.quillBinding = new QuillBinding(ytext, quill)

View File

@@ -1,29 +0,0 @@
footer img {
display: none;
}
nav .title h1 a {
display: none;
}
footer {
background-color: #b93c1d;
}
#resizer {
background-color: #b93c1d;
}
.main section article.readme h1:first-child img {
display: none;
}
.main section article.readme h1:first-child {
margin-bottom: 16px;
margin-top: 30px;
}
.main section article.readme h1:first-child::before {
content: "Yjs";
font-size: 2em;
}

View File

@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Yjs Textarea Example</title>
</head>
<body>
<p>This example shows how to bind a YText type to a DOM Textarea.</p>
<p>The content of this textarea is shared with every client who visits this domain.</p>
<div class="code-html">
<textarea style="width:80%;" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<!-- The actual source file for the following code is found in ./textarea.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/textarea.js" type="module">
import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { TextareaBinding } from 'yjs/bindings/textarea.js'
import * as conf from './exampleConfig.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('textarea')
const type = ydocument.define('textarea', Y.Text)
const textarea = document.querySelector('textarea')
const binding = new TextareaBinding(type, textarea)
window.textareaExample = {
provider, ydocument, type, textarea, binding
}
</script>
</body>
</html>

View File

@@ -1,14 +0,0 @@
import { WebsocketProvider } from 'y-websocket'
import { TextareaBinding } from 'y-textarea'
import * as conf from './exampleConfig.js'
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('textarea')
const type = ydocument.getText('textarea')
const textarea = document.querySelector('textarea')
const binding = new TextareaBinding(type, textarea)
window.textareaExample = {
provider, ydocument, type, textarea, binding
}

1048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,19 @@
{
"name": "yjs",
"version": "13.0.0-81",
"version": "13.0.0-85",
"description": "Shared Editing Library",
"main": "./dist/yjs.js",
"module": "./src/index.js",
"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'",
"dist": "rm -rf dist && rollup -c",
"watch": "rollup -wc",
"lint": "standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.md --package ./package.json || true",
"serve-docs": "npm run docs && serve ./docs/",
"preversion": "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",
@@ -28,15 +27,13 @@
],
"dictionaries": {
"doc": "docs",
"example": "examples",
"test": "tests"
},
"standard": {
"ignore": [
"/dist",
"/node_modules",
"/docs",
"/examples/build"
"/docs"
]
},
"repository": {
@@ -54,27 +51,20 @@
},
"homepage": "http://y-js.org",
"dependencies": {
"lib0": "0.0.2"
"lib0": "0.0.5"
},
"devDependencies": {
"y-protocols": "0.0.3",
"codemirror": "^5.42.0",
"concurrently": "^3.6.1",
"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",
"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.6"
}
}

View File

@@ -23,12 +23,14 @@ const debugResolve = {
if (importee === 'yjs') {
return `${process.cwd()}/src/index.js`
}
/*
if (customModules.has(importee.split('/')[0])) {
return `${process.cwd()}/../${importee}/src/${importee}.js`
}
if (customLibModules.has(importee.split('/')[0])) {
return `${process.cwd()}/../${importee}`
}
*/
return null
}
}
@@ -80,26 +82,8 @@ export default [{
debugResolve,
nodeResolve({
sourcemap: true,
module: true,
browser: true
}),
commonjs()
]
}, {
input: ['./examples/textarea.js', './examples/prosemirror.js'], // './examples/quill.js', './examples/dom.js', './examples/codemirror.js'
output: {
dir: 'examples/build',
format: 'esm',
sourcemap: true
},
plugins: [
debugResolve,
nodeResolve({
sourcemap: true,
module: true,
browser: true
}),
commonjs(),
...minificationPlugins
mainFields: ['module', 'browser', 'main']
})
// commonjs()
]
}]

View File

@@ -1,6 +1,6 @@
export {
Y,
Doc,
Transaction,
YArray as Array,
YMap as Map,
@@ -24,25 +24,23 @@ export {
ItemString,
ItemType,
AbstractType,
compareCursors,
Cursor,
createCursorFromTypeOffset,
createCursorFromJSON,
createAbsolutePositionFromCursor,
writeCursor,
readCursor,
RelativePosition,
createRelativePositionFromTypeIndex,
createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition,
compareRelativePositions,
writeRelativePosition,
readRelativePosition,
ID,
createID,
compareIDs,
getState,
getStates,
readStatesAsMap,
writeStates,
writeModel,
readModel,
Snapshot,
findRootTypeKey,
typeArrayToArraySnapshot,
typeListToArraySnapshot,
typeMapGetSnapshot,
iterateDeletedStructs
iterateDeletedStructs,
applyUpdate,
encodeStateAsUpdate,
encodeStateVector
} from './internals.js'

View File

@@ -2,12 +2,12 @@ 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'

View File

@@ -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'
@@ -153,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
@@ -285,7 +285,7 @@ export class AbstractItem extends AbstractStruct {
* @private
*/
copy (id, left, origin, right, rightOrigin, parent, parentSub) {
throw new Error('unimplemented')
throw error.methodUnimplemented()
}
/**
@@ -393,7 +393,7 @@ export class AbstractItem extends AbstractStruct {
* @private
*/
splitAt (transaction, diff) {
throw new Error('unimplemented')
throw error.methodUnimplemented()
}
/**
@@ -415,23 +415,19 @@ export class AbstractItem extends AbstractStruct {
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
*
* @private
*/
gcChildren (transaction, store) { }
gcChildren (store) { }
/**
* @todo remove transaction param
*
* @param {Transaction} transaction
* @param {StructStore} store
* @param {boolean} parentGCd
*
* @private
*/
gc (transaction, store, parentGCd) {
gc (store, parentGCd) {
if (!this.deleted) {
throw error.unexpectedCase()
}
@@ -619,12 +615,16 @@ export const computeItemParams = (transaction, store, leftid, rightid, parentid,
case GC:
break
default:
// 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

View File

@@ -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

View File

@@ -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)
}
/**

View File

@@ -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

View File

@@ -8,7 +8,7 @@ import {
splitItem,
addToDeleteSet,
mergeItemWith,
Y, StructStore, Transaction, ID, AbstractType // eslint-disable-line
StructStore, Transaction, ID, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
@@ -87,15 +87,14 @@ export class ItemDeleted extends AbstractItem {
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {boolean} parentGCd
*
* @private
*/
gc (transaction, store, parentGCd) {
gc (store, parentGCd) {
if (parentGCd) {
super.gc(transaction, store, parentGCd)
super.gc(store, parentGCd)
}
}
/**

View File

@@ -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))
}

View File

@@ -7,7 +7,7 @@ import {
changeItemRefOffset,
GC,
mergeItemWith,
Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line
Transaction, StructStore, ID, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'

View File

@@ -7,7 +7,7 @@ import {
changeItemRefOffset,
GC,
mergeItemWith,
Transaction, StructStore, Y, ID, AbstractType // eslint-disable-line
Transaction, StructStore, ID, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'

View File

@@ -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
@@ -129,19 +129,18 @@ export class ItemType extends AbstractItem {
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
*/
gcChildren (transaction, store) {
gcChildren (store) {
let item = this.type._start
while (item !== null) {
item.gc(transaction, store, true)
item.gc(store, true)
item = item.right
}
this.type._start = null
this.type._map.forEach(item => {
while (item !== null) {
item.gc(transaction, store, true)
item.gc(store, true)
// @ts-ignore
item = item.left
}
@@ -150,13 +149,12 @@ export class ItemType extends AbstractItem {
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {boolean} parentGCd
*/
gc (transaction, store, parentGCd) {
this.gcChildren(transaction, store)
super.gc(transaction, store, parentGCd)
gc (store, parentGCd) {
this.gcChildren(store)
super.gc(store, parentGCd)
}
}

View File

@@ -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) {
@@ -205,7 +205,7 @@ export const typeArrayToArray = type => {
* @private
* @function
*/
export const typeArrayToArraySnapshot = (type, snapshot) => {
export const typeListToArraySnapshot = (type, snapshot) => {
const cs = []
let n = type._start
while (n !== null) {
@@ -229,7 +229,7 @@ export const typeArrayToArraySnapshot = (type, snapshot) => {
* @private
* @function
*/
export const typeArrayForEach = (type, f) => {
export const typeListForEach = (type, f) => {
let index = 0
let n = type._start
while (n !== null) {
@@ -252,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
@@ -270,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}
@@ -326,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) {
@@ -348,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) {
@@ -363,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>|boolean|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
/**
@@ -386,6 +386,7 @@ export const typeArrayInsertGenericsAfter = (transaction, parent, referenceItem,
switch (c.constructor) {
case Number:
case Object:
case Boolean:
case Array:
case String:
jsonContent.push(c)
@@ -393,10 +394,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:
@@ -416,14 +416,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) {
@@ -431,14 +431,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)
}
/**
@@ -450,14 +450,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 && index > 0; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
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))
}
index -= n.length
}
@@ -466,7 +466,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
@@ -497,7 +497,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
@@ -511,11 +511,12 @@ export const typeMapSet = (transaction, parent, key, value) => {
switch (value.constructor) {
case Number:
case Object:
case Boolean:
case Array:
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:
@@ -530,7 +531,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
@@ -542,7 +543,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
@@ -577,7 +578,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

View File

@@ -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
@@ -101,9 +101,9 @@ export class YArray extends AbstractType {
* @param {Array<T>} content The array of content
*/
insert (index, content) {
if (this._y !== null) {
transact(this._y, transaction => {
typeArrayInsertGenerics(transaction, this, 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
@@ -127,9 +127,9 @@ export class YArray extends AbstractType {
* @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)
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
@@ -144,7 +144,7 @@ export class YArray extends AbstractType {
* @return {T}
*/
get (index) {
return typeArrayGet(this, index)
return typeListGet(this, index)
}
/**
@@ -153,7 +153,7 @@ export class YArray extends AbstractType {
* @return {Array<T>}
*/
toArray () {
return typeArrayToArray(this)
return typeListToArray(this)
}
/**
@@ -176,7 +176,7 @@ export class YArray extends AbstractType {
*/
map (f) {
// @ts-ignore
return typeArrayMap(this, f)
return typeListMap(this, f)
}
/**
@@ -185,14 +185,14 @@ export class YArray extends AbstractType {
* @param {function(T,number,YArray<T>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
typeArrayForEach(this, f)
typeListForEach(this, f)
}
/**
* @return {IterableIterator<T>}
*/
[Symbol.iterator] () {
return typeArrayCreateIterator(this)
return typeListCreateIterator(this)
}
/**

View File

@@ -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
@@ -144,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 {
@@ -161,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 {

View File

@@ -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
@@ -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
@@ -629,7 +635,7 @@ export class YText extends AbstractType {
}
/**
* @param {Y} y
* @param {Doc} y
* @param {ItemType} item
*
* @private
@@ -686,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}
*/
@@ -803,7 +809,7 @@ 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)
@@ -829,7 +835,7 @@ 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)
@@ -853,7 +859,7 @@ 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)
@@ -876,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)

View File

@@ -6,9 +6,9 @@ import {
typeMapSet,
typeMapGet,
typeMapGetAll,
typeArrayForEach,
typeListForEach,
YXmlElementRefID,
Snapshot, Y, ItemType // eslint-disable-line
Snapshot, Doc, ItemType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
@@ -39,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
*/
@@ -100,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 {
@@ -119,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 {
@@ -176,7 +176,7 @@ export class YXmlElement extends YXmlFragment {
for (let key in attrs) {
dom.setAttribute(key, attrs[key])
}
typeArrayForEach(this, yxml => {
typeListForEach(this, yxml => {
dom.appendChild(yxml.toDOM(_document, hooks, binding))
})
if (binding !== undefined) {

View File

@@ -6,11 +6,11 @@ import {
YXmlEvent,
YXmlElement,
AbstractType,
typeArrayMap,
typeArrayForEach,
typeArrayInsertGenerics,
typeArrayDelete,
typeArrayToArray,
typeListMap,
typeListForEach,
typeListInsertGenerics,
typeListDelete,
typeListToArray,
YXmlFragmentRefID,
callTypeObservers,
transact,
@@ -211,7 +211,7 @@ export class YXmlFragment extends AbstractType {
* @return {string} The string representation of all children.
*/
toString () {
return typeArrayMap(this, xml => xml.toString()).join('')
return typeListMap(this, xml => xml.toString()).join('')
}
toJSON () {
@@ -238,7 +238,7 @@ export class YXmlFragment extends AbstractType {
if (binding !== undefined) {
binding._createAssociation(fragment, this)
}
typeArrayForEach(this, xmlType => {
typeListForEach(this, xmlType => {
fragment.insertBefore(xmlType.toDOM(_document, hooks, binding), null)
})
return fragment
@@ -255,9 +255,9 @@ export class YXmlFragment extends AbstractType {
* @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)
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
@@ -272,9 +272,9 @@ export class YXmlFragment extends AbstractType {
* @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)
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
@@ -287,7 +287,7 @@ export class YXmlFragment extends AbstractType {
* @return {Array<YXmlElement|YXmlText|YXmlHook>}
*/
toArray () {
return typeArrayToArray(this)
return typeListToArray(this)
}
/**
* Transform the properties of this type to binary and write it to an

View File

@@ -48,7 +48,7 @@ export class DeleteSet {
/**
* Iterate over all structs that were deleted.
*
* This function expects that the deletes structs are not deleted. Hence, you can
* 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`.
*
@@ -266,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)))
}
}

View File

@@ -10,26 +10,23 @@ import {
YMap,
YXmlFragment,
transact,
Transaction, YEvent // eslint-disable-line
AbstractItem, Transaction, YEvent // eslint-disable-line
} from '../internals.js'
import { Observable } from 'lib0/observable.js'
import * as random from 'lib0/random.js'
import * as map from 'lib0/map.js'
// @todo rename to shared document
/**
* 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>>}
@@ -82,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
@@ -97,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)

View File

@@ -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
}

View File

@@ -1,6 +1,3 @@
/**
* @module Cursors
*/
import {
getItem,
@@ -13,7 +10,7 @@ import {
findRootTypeKey,
AbstractItem,
ItemType,
ID, StructStore, Y, AbstractType // eslint-disable-line
ID, StructStore, Doc, AbstractType // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
@@ -21,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
@@ -63,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>}
*/
@@ -99,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
@@ -117,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) {
@@ -125,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)
@@ -177,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
@@ -203,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
@@ -228,18 +227,18 @@ 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) {
if (getState(store, typeID.client) <= typeID.clock) {
// type does not exist yet
@@ -255,20 +254,20 @@ export const createAbsolutePositionFromCursor = (cursor, y) => {
} 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 || (
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)
)

View File

@@ -6,12 +6,10 @@ import {
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
@@ -23,14 +21,15 @@ export class Snapshot {
* @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

View File

@@ -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
}

View File

@@ -6,11 +6,11 @@ import {
writeDeleteSet,
DeleteSet,
sortAndMergeDeleteSet,
getStates,
getStateVector,
findIndexSS,
callEventHandlerListeners,
AbstractItem,
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'
@@ -43,15 +43,15 @@ import * as math from 'lib0/math.js'
*/
export class Transaction {
/**
* @param {Y} y
* @param {Doc} doc
* @param {any} origin
*/
constructor (y, origin) {
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,11 +80,6 @@ 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
@@ -95,21 +90,20 @@ export class Transaction {
*/
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
}
/**
@@ -119,45 +113,44 @@ 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, origin = null) => {
const transactionCleanups = y._transactionCleanups
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, origin)
transactionCleanups.push(y._transaction)
y.emit('beforeTransaction', [y._transaction, y])
doc._transaction = new Transaction(doc, origin)
transactionCleanups.push(doc._transaction)
doc.emit('beforeTransaction', [doc._transaction, doc])
}
try {
f(y._transaction)
f(doc._transaction)
} finally {
// @todo set after state here
if (initialCall && transactionCleanups[0] === y._transaction) {
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.y.store
const store = transaction.doc.store
const ds = transaction.deleteSet
sortAndMergeDeleteSet(ds)
transaction.afterState = getStates(transaction.y.store)
y._transaction = null
y.emit('beforeObserverCalls', [transaction, y])
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)
@@ -175,7 +168,7 @@ export const transact = (y, f, origin = null) => {
// because we know it has at least one element
callEventHandlerListeners(type._dEH, events, transaction)
})
y.emit('afterTransaction', [transaction, y])
doc.emit('afterTransaction', [transaction, doc])
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
@@ -213,7 +206,7 @@ export const transact = (y, f, origin = null) => {
break
}
if (struct.deleted && struct instanceof AbstractItem) {
struct.gc(transaction, store, false)
struct.gc(store, false)
}
}
}
@@ -276,10 +269,15 @@ export const transact = (y, f, origin = null) => {
}
}
// @todo Merge all the transactions into one and provide send the data as a single update message
// @todo implement a dedicatet event that we can use to send updates to other peer
y.emit('afterTransactionCleanup', [transaction, y])
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])
}
}
}
y._transactionCleanups = []
doc._transactionCleanups = []
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -2,9 +2,9 @@ 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'
@@ -13,24 +13,9 @@ import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
import * as syncProtocol from 'y-protocols/sync.js'
/**
* @param {Y.Transaction} transaction
* @param {TestYInstance} y
*/
const afterTransaction = (transaction, y) => {
if (transaction.origin !== y.tc) {
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)) {
@@ -42,7 +27,7 @@ const broadcastMessage = (y, m) => {
}
}
export class TestYInstance extends Y.Y {
export class TestYInstance extends Doc {
/**
* @param {TestConnector} testConnector
* @param {number} clientID
@@ -55,12 +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()
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()
}
/**
@@ -78,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)
}
})
}
@@ -95,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) {
@@ -164,7 +155,7 @@ export class TestConnector {
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
}
@@ -282,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)
}
@@ -369,7 +360,7 @@ export const compareDS = (ds1, ds2) => {
/**
* @template T
* @param {t.TestCase} tc
* @param {Array<function(TestYInstance,prng.PRNG,T):void>} mods
* @param {Array<function(Y.Doc,prng.PRNG,T):void>} mods
* @param {number} iterations
* @param {InitTestObjectCallback<T>} [initTestObject]
*/

View File

@@ -25,10 +25,10 @@ export const testDeleteInsert = tc => {
*/
export const testInsertThreeElementsTryRegetProperty = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, [1, 2, 3])
t.compare(array0.toJSON(), [1, 2, 3], '.toJSON() works')
array0.insert(0, [1, true, false])
t.compare(array0.toJSON(), [1, true, false], '.toJSON() works')
testConnector.flushAllMessages()
t.compare(array1.toJSON(), [1, 2, 3], '.toJSON() works after sync')
t.compare(array1.toJSON(), [1, true, false], '.toJSON() works after sync')
compare(users)
}
@@ -263,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++) {

View File

@@ -19,6 +19,8 @@ export const testBasicMapTests = tc => {
map0.set('string', 'hello Y')
map0.set('object', { key: { key2: 'value' } })
map0.set('y-map', new Y.Map())
map0.set('boolean1', true)
map0.set('boolean0', false)
const map = map0.get('y-map')
map.set('y-array', new Y.Array())
const array = map.get('y-array')
@@ -27,6 +29,8 @@ export const testBasicMapTests = tc => {
t.assert(map0.get('number') === 1, 'client 0 computed the change (number)')
t.assert(map0.get('string') === 'hello Y', 'client 0 computed the change (string)')
t.assert(map0.get('boolean0') === false, 'client 0 computed the change (boolean)')
t.assert(map0.get('boolean1') === true, 'client 0 computed the change (boolean)')
t.compare(map0.get('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
t.assert(map0.get('y-map').get('y-array').get(0) === -1, 'client 0 computed the change (type)')
@@ -35,12 +39,16 @@ export const testBasicMapTests = tc => {
t.assert(map1.get('number') === 1, 'client 1 received the update (number)')
t.assert(map1.get('string') === 'hello Y', 'client 1 received the update (string)')
t.assert(map1.get('boolean0') === false, 'client 1 computed the change (boolean)')
t.assert(map1.get('boolean1') === true, 'client 1 computed the change (boolean)')
t.compare(map1.get('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
t.assert(map1.get('y-map').get('y-array').get(0) === -1, 'client 1 received the update (type)')
// compare disconnected user
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
t.assert(map2.get('string') === 'hello Y', 'client 2 received the update (string) - was disconnected')
t.assert(map2.get('boolean0') === false, 'client 2 computed the change (boolean)')
t.assert(map2.get('boolean1') === true, 'client 2 computed the change (boolean)')
t.compare(map2.get('object'), { key: { key2: 'value' } }, 'client 2 received the update (object) - was disconnected')
t.assert(map2.get('y-map').get('y-array').get(0) === -1, 'client 2 received the update (type) - was disconnected')
compare(users)

View File

@@ -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)
}

View File

@@ -54,7 +54,7 @@
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"maxNodeModuleJsDepth": 1,
"maxNodeModuleJsDepth": 5,
// "types": ["./src/utils/typedefs.js"]
},
"include": ["./src/**/*", "./tests/**/*"],