Merge branch 'master' of github.com:y-js/yjs

This commit is contained in:
Kevin Jahns 2019-11-26 13:02:49 +01:00
commit 18c9010b63
31 changed files with 2422 additions and 1541 deletions

12
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,12 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: dmonad
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@ -17,7 +17,7 @@
"useCollapsibles": true,
"collapse": true,
"resources": {
"y-js.org": "yjs.website"
"yjs.dev": "Yjs website"
},
"logo": {
"url": "https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png",

1107
README.md

File diff suppressed because it is too large Load Diff

305
README.v12.md Normal file
View File

@ -0,0 +1,305 @@
# ![Yjs](https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png)
Yjs is a framework for offline-first p2p shared editing on structured data like
text, richtext, json, or XML. It is fairly easy to get started, as Yjs hides
most of the complexity of concurrent editing. For additional information, demos,
and tutorials visit [y-js.org](http://y-js.org/).
:warning: Checkout the [v13 docs](./README.md) for the upcoming release :warning:
### Extensions
Yjs only knows how to resolve conflicts on shared data. You have to choose a ..
* *Connector* - a communication protocol that propagates changes to the clients
* *Database* - a database to store your changes
* one or more *Types* - that represent the shared data
Connectors, Databases, and Types are available as modules that extend Yjs. Here
is a list of the modules we know of:
##### Connectors
|Name | Description |
|----------------|-----------------------------------|
|[webrtc](https://github.com/y-js/y-webrtc) | Propagate updates Browser2Browser via WebRTC|
|[websockets](https://github.com/y-js/y-websockets-client) | Set up [a central server](https://github.com/y-js/y-websockets-client), and connect to it via websockets |
|[xmpp](https://github.com/y-js/y-xmpp) | Propagate updates in a XMPP multi-user-chat room ([XEP-0045](http://xmpp.org/extensions/xep-0045.html))|
|[ipfs](https://github.com/ipfs-labs/y-ipfs-connector) | Connector for the [Interplanetary File System](https://ipfs.io/)!|
|[test](https://github.com/y-js/y-test) | A Connector for testing purposes. It is designed to simulate delays that happen in worst case scenarios|
##### Database adapters
|Name | Description |
|----------------|-----------------------------------|
|[memory](https://github.com/y-js/y-memory) | In-memory storage. |
|[indexeddb](https://github.com/y-js/y-indexeddb) | Offline storage for the browser |
|[leveldb](https://github.com/y-js/y-leveldb) | Persistent storage for node apps |
##### Types
| Name | Description |
|----------|-------------------|
|[map](https://github.com/y-js/y-map) | A shared Map implementation. Maps from text to any stringify-able object |
|[array](https://github.com/y-js/y-array) | A shared Array implementation |
|[xml](https://github.com/y-js/y-xml) | An implementation of the DOM. You can create a two way binding to Browser DOM objects |
|[text](https://github.com/y-js/y-text) | Collaborate on text. Supports two way binding to the [Ace Editor](https://ace.c9.io), [CodeMirror](https://codemirror.net/), [Monaco](https://github.com/Microsoft/monaco-editor), textareas, input elements, and HTML elements (e.g. <*h1*>, or <*p*>) |
|[richtext](https://github.com/y-js/y-richtext) | Collaborate on rich text. Supports two way binding to the [Quill Rich Text Editor](http://quilljs.com/)|
##### Other
| Name | Description |
|-----------|-------------------|
|[y-element](http://y-js.org/y-element/) | Yjs Polymer Element |
## Use it!
Install Yjs, and its modules with [bower](http://bower.io/), or
[npm](https://www.npmjs.org/package/yjs).
### Bower
```
bower install --save yjs y-array % add all y-* modules you want to use
```
You only need to include the `y.js` file. Yjs is able to automatically require
missing modules.
```
<script src="./bower_components/yjs/y.js"></script>
```
### CDN
```
<script src="https://cdn.jsdelivr.net/npm/yjs@12/dist/y.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-array@10/dist/y-array.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-websockets-client@8/dist/y-websockets-client.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-memory@8/dist/y-memory.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-map@10/dist/y-map.js"></script>
<script src="https://cdn.jsdelivr.net/npm/y-text@9/dist/y-text.js"></script>
// ..
// do the same for all modules you want to use
```
### Npm
```
npm install --save yjs % add all y-* modules you want to use
```
If you don't include via script tag, you have to explicitly include all modules!
(Same goes for other module systems)
```
var Y = require('yjs')
require('y-array')(Y) // add the y-array type to Yjs
require('y-websockets-client')(Y)
require('y-memory')(Y)
require('y-map')(Y)
require('y-text')(Y)
// ..
// do the same for all modules you want to use
```
### ES6 Syntax
```
import Y from 'yjs'
import yArray from 'y-array'
import yWebsocketsClient from 'y-webrtc'
import yMemory from 'y-memory'
import yMap from 'y-map'
import yText from 'y-text'
// ..
Y.extend(yArray, yWebsocketsClient, yMemory, yArray, yMap, yText /*, .. */)
```
# Text editing example
Install dependencies
```
bower i yjs y-memory y-webrtc y-array y-text
```
Here is a simple example of a shared textarea
```HTML
<!DOCTYPE html>
<html>
<body>
<script src="./bower_components/yjs/y.js"></script>
<!-- Yjs automatically includes all missing dependencies (browser only) -->
<script>
Y({
db: {
name: 'memory' // use memory database adapter.
// name: 'indexeddb' // use indexeddb database adapter instead for offline apps
},
connector: {
name: 'webrtc', // use webrtc connector
// name: 'websockets-client'
// name: 'xmpp'
room: 'my-room' // clients connecting to the same room share data
},
sourceDir: './bower_components', // location of the y-* modules (browser only)
share: {
textarea: 'Text' // y.share.textarea is of type y-text
}
}).then(function (y) {
// The Yjs instance `y` is available
// y.share.* contains the shared types
// Bind `y.share.textarea` to `<textarea/>`
y.share.textarea.bind(document.querySelector('textarea'))
})
</script>
<textarea></textarea>
</body>
</html>
```
## Get Help & Give Help
There are some friendly people on [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/y-js/yjs?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) who are eager to help, and answer questions. Please join!
Report _any_ issues to the
[Github issue page](https://github.com/y-js/yjs/issues)! I try to fix them very
soon, if possible.
# API
### Y(options)
* Y.extend(module1, module2, ..)
* Add extensions to Y
* `Y.extend(require('y-webrtc'))` has the same semantics as
`require('y-webrtc')(Y)`
* options.db
* Will be forwarded to the database adapter. Specify the database adaper on
`options.db.name`.
* Have a look at the used database adapter repository to see all available
options.
* options.connector
* Will be forwarded to the connector adapter. Specify the connector adaper on
`options.connector.name`.
* All our connectors implement a `room` property. Clients that specify the
same room share the same data.
* All of our connectors specify an `url` property that defines the connection
endpoint of the used connector.
* All of our connectors also have a default connection endpoint that you can
use for development.
* Set `options.connector.generateUserId = true` in order to genenerate a
userid, instead of receiving one from the server. This way the `Y(..)` is
immediately going to be resolved, without waiting for any confirmation from
the server. Use with caution.
* Have a look at the used connector repository to see all available options.
* *Only if you know what you are doing:* Set
`options.connector.preferUntransformed = true` in order receive the shared
data untransformed. This is very efficient as the database content is simply
copied to this client. This does only work if this client receives content
from only one client.
* options.sourceDir (browser only)
* Path where all y-* modules are stored
* Defaults to `/bower_components`
* Not required when running on `nodejs` / `iojs`
* When using nodejs you need to manually extend Yjs:
```
var Y = require('yjs')
// you have to require a db, connector, and *all* types you use!
require('y-memory')(Y)
require('y-webrtc')(Y)
require('y-map')(Y)
// ..
```
* options.share
* Specify on `options.share[arbitraryName]` types that are shared among all
users.
* E.g. Specify `options.share[arbitraryName] = 'Array'` to require y-array and
create an y-array type on `y.share[arbitraryName]`.
* If userA doesn't specify `options.share[arbitraryName]`, it won't be
available for userA.
* If userB specifies `options.share[arbitraryName]`, it still won't be
available for userA. But all the updates are send from userB to userA.
* In contrast to y-map, types on `y.share.*` cannot be overwritten or deleted.
Instead, they are merged among all users. This feature is only available on
`y.share.*`
* Weird behavior: It is supported that two users specify different types with
the same property name.
E.g. userA specifies `options.share.x = 'Array'`, and userB specifies
`options.share.x = 'Text'`. But they only share data if they specified the
same type with the same property name
* options.type (browser only)
* Array of modules that Yjs needs to require, before instantiating a shared
type.
* By default Yjs requires the specified database adapter, the specified
connector, and all modules that are used in `options.share.*`
* Put all types here that you intend to use, but are not used in y.share.*
### Instantiated Y object (y)
`Y(options)` returns a promise that is fulfilled when..
* All modules are loaded
* The specified database adapter is loaded
* The specified connector is loaded
* All types are included
* The connector is initialized, and a unique user id is set (received from the
server)
* Note: When using y-indexeddb, a retrieved user id is stored on `localStorage`
The promise returns an instance of Y. We denote it with a lower case `y`.
* y.share.*
* Instances of the types you specified on options.share.*
* y.share.* can only be defined once when you instantiate Y!
* y.connector is an instance of Y.AbstractConnector
* y.connector.onUserEvent(function (event) {..})
* Observe user events (event.action is either 'userLeft' or 'userJoined')
* y.connector.whenSynced(listener)
* `listener` is executed when y synced with at least one user.
* `listener` is not called when no other user is in the same room.
* y-websockets-client aways waits to sync with the server
* y.connector.disconnect()
* Force to disconnect this instance from the other instances
* y.connector.connect()
* Try to reconnect to the other instances (needs to be supported by the
connector)
* Not supported by y-xmpp
* y.close()
* Destroy this object.
* Destroys all types (they will throw weird errors if you still use them)
* Disconnects from the other instances (via connector)
* Returns a promise
* y.destroy()
* calls y.close()
* Removes all data from the database
* Returns a promise
* y.db.stopGarbageCollector()
* Stop the garbage collector. Call y.db.garbageCollect() to continue garbage
collection
* y.db.gc :: Boolean
* Whether gc is turned on
* y.db.gcTimeout :: Number (defaults to 50000 ms)
* Time interval between two garbage collect cycles
* It is required that all instances exchanged all messages after two garbage
collect cycles (after 100000 ms per default)
* y.db.userId :: String
* The used user id for this client. **Never overwrite this**
### Logging
Yjs uses [debug](https://github.com/visionmedia/debug) for logging. The flag
`y*` enables logging for all y-* components. You can selectively remove
components you are not interested in: E.g. The flag `y*,-y:connector-message`
will not log the long `y:connector-message` messages.
##### Enable logging in Node.js
```sh
DEBUG=y* node app.js
```
Remove the colors in order to log to a file:
```sh
DEBUG_COLORS=0 DEBUG=y* node app.js > log
```
##### Enable logging in the browser
```js
localStorage.debug = 'y*'
```
## License
Yjs is licensed under the [MIT License](./LICENSE).

View File

@ -1,869 +0,0 @@
# ![Yjs](https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png)
> A CRDT framework with a powerful abstraction of shared data
Yjs is a [CRDT implementation](#Yjs-CRDT-Algorithm) that exposes its internal
data structure as *shared types*. Shared types are common data types like `Map`
or `Array` with superpowers: changes are automatically distributed to other
peers and merged without merge conflicts.
Yjs is **network agnostic** (p2p!), supports many existing **rich text
editors**, **offline editing**, **version snapshots**, **undo/redo** and
**shared cursors**. It scales well with an unlimited number of users and is well
suited for even large documents.
* Chat: [https://gitter.im/y-js/yjs](https://gitter.im/y-js/yjs)
* Demos: [https://github.com/y-js/yjs-demos](https://github.com/y-js/yjs-demos)
* Benchmarks: [https://github.com/dmonad/crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks)
## Table of Contents
* [Overview](#Overview)
* [Bindings](#Bindings)
* [Providers](#Providers)
* [Getting Started](#Getting-Started)
* [API](#API)
* [Shared Types](#Shared-Types)
* [Y.Doc](#YDoc)
* [Document Updates](#Document-Updates)
* [Relative Positions](#Relative-Positions)
* [Y.UndoManager](#YUndoManager)
* [Miscellaneous](#Miscellaneous)
* [Typescript Declarations](#Typescript-Declarations)
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
* [Evaluation](#Evaluation)
* [Existing shared editing libraries](#Exisisting-Javascript-Libraries)
* [CRDT Algorithms](#CRDT-Algorithms)
* [Comparison of CRDT with OT](#Comparing-CRDT-with-OT)
* [Comparison of CRDT Algorithms](#Comparing-CRDT-Algorithms)
* [Comparison of Yjs with other Implementations](#Comparing-Yjs-with-other-Implementations)
* [License and Author](#License-and-Author)
## Overview
This repository contains a collection of shared types that can be observed for
changes and manipulated concurrently. Network functionality and two-way-bindings
are implemented in separate modules.
### Bindings
| Name | Cursors | Binding | Demo |
|---|:-:|---|---|
| [ProseMirror](https://prosemirror.net/) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | ✔ | [y-prosemirror](http://github.com/y-js/y-prosemirror) | [demo](https://yjs-demos.now.sh/prosemirror/) |
| [Quill](https://quilljs.com/) | | [y-quill](http://github.com/y-js/y-quill) | [demo](https://yjs-demos.now.sh/quill/) |
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/y-js/y-codemirror) | [demo](https://yjs-demos.now.sh/codemirror/) |
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](http://github.com/y-js/y-monaco) | [demo](https://yjs-demos.now.sh/monaco/) |
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/y-js/y-ace) | [demo](https://yjs-demos.now.sh/ace/) |
| [Textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) | | [y-textarea](http://github.com/y-js/y-textarea) | [demo](https://yjs-demos.now.sh/textarea/) |
| [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) | | [y-dom](http://github.com/y-js/y-dom) | [demo](https://yjs-demos.now.sh/dom/) |
### Providers
Setting up the communication between clients, managing awareness information,
and storing shared data for offline usage is quite a hassle. **Providers**
manage all that for you and are the perfect starting point for your
collaborative app.
<dl>
<dt><a href="http://github.com/y-js/y-websocket">y-websocket</a></dt>
<dd>
A module that contains a simple websocket backend and a websocket client that
connects to that backend. The backend can be extended to persist updates in a
leveldb database.
</dd>
<dt><a href="http://github.com/y-js/y-mesh">y-mesh</a></dt>
<dd>
[WIP] Creates a connected graph of webrtc connections with a high
<a href="https://en.wikipedia.org/wiki/Strength_of_a_graph">strength</a>. It
requires a signalling server that connects a client to the first peer. But after
that the network manages itself. It is well suited for large and small networks.
</dd>
<dt><a href="http://github.com/y-js/y-dat">y-dat</a></dt>
<dd>
[WIP] Write document updates effinciently to the dat network using
<a href="https://github.com/kappa-db/multifeed">multifeed</a>. Each client has
an append-only log of CRDT local updates (hypercore). Multifeed manages and sync
hypercores and y-dat listens to changes and applies them to the Yjs document.
</dd>
</dl>
## Getting Started
Install Yjs and a provider with your favorite package manager:
```sh
npm i yjs@13.0.0-82 y-websocket@1.0.0-3 y-textarea
```
Start the y-websocket server:
```sh
PORT=1234 node ./node_modules/y-websocket/bin/server.js
```
### Example: Textarea Binding
This is a complete example on how to create a connection to a
[y-websocket](https://github.com/y-js/y-websocket) server instance, sync the
shared document to all clients in a *room*, and bind a Y.Text type to a dom
textarea. All changes to the textarea are automatically shared with everyone in
the same room.
```js
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { TextareaBinding } from 'y-textarea'
const doc = Y.Doc()
const provider = new WebsocketProvider('ws://localhost:1234', 'roomname', doc)
// Define a shared type on the document.
const ytext = doc.getText('my resume')
// use data bindings to bind types to editors
const binding = new TextareaBinding(ytext, document.querySelector('textarea'))
```
#### Example: Observe types
```js
const yarray = doc.getArray('my-array')
yarray.observe(event => {
console.log('yarray was modified')
})
// every time a local or remote client modifies yarray, the observer is called
yarray.insert(0, ['val']) // => "yarray was modified"
```
#### Example: Nest types
Remember, shared types are just plain old data types. The only limitation is
that a shared type must exist only once in the shared document.
```js
const ymap = doc.getMap('map')
const foodArray = new Y.Array()
foodArray.insert(0, ['apple', 'banana'])
ymap.set('food', foodArray)
ymap.get('food') === foodArray // => true
ymap.set('fruit', foodArray) // => Error! foodArray is already defined
```
Now you understand how types are defined on a shared document. Next you can jump
to the [demo repository](https://github.com/y-js/yjs-demos) or continue reading
the API docs.
## API
```js
import * as Y from 'yjs'
```
### Shared Types
<details>
<summary><b>Y.Array</b></summary>
<br>
<p>
A shareable Array-like type that supports efficient insert/delete of elements
at any position. Internally it uses a linked list of Arrays that is split when
necessary.
</p>
<pre>const yarray = new Y.Array()</pre>
<dl>
<b><code>insert(index:number, content:Array&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|boolean|Array|string|number|Uint8Array|Y.Type&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>length:number</code></b>
<dd></dd>
<b><code>forEach(function(index:number,value:object|boolean|Array|string|number|Uint8Array|Y.Type))</code></b>
<dd></dd>
<b><code>map(function(T, number, YArray):M):Array&lt;M&gt;</code></b>
<dd></dd>
<b><code>toArray():Array&lt;object|boolean|Array|string|number|Uint8Array|Y.Type&gt;</code></b>
<dd>Copies the content of this YArray to a new Array.</dd>
<b><code>toJSON():Array&lt;Object|boolean|Array|string|number&gt;</code></b>
<dd>
Copies the content of this YArray to a new Array. It transforms all child types
to JSON using their <code>toJSON</code> method.
</dd>
<b><code>[Symbol.Iterator]</code></b>
<dd>
Returns an YArray Iterator that contains the values for each index in the array.
<pre>for (let value of yarray) { .. }</pre>
</dd>
<b><code>observe(function(YArrayEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
this type is modified. In the case this type is modified in the event listener,
the event listener will be called again after the current event listener returns.
</dd>
<b><code>unobserve(function(YArrayEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
this type or any of its children is modified. In the case this type is modified
in the event listener, the event listener will be called again after the current
event listener returns. The event listener receives all Events created by itself
or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
<details>
<summary><b>Y.Map</b></summary>
<br>
<p>
A shareable Map type.
</p>
<pre><code>const ymap = new Y.Map()</code></pre>
<dl>
<b><code>get(key:string):object|boolean|string|number|Uint8Array|Y.Type</code></b>
<dd></dd>
<b><code>set(key:string, value:object|boolean|string|number|Uint8Array|Y.Type)</code></b>
<dd></dd>
<b><code>delete(key:string)</code></b>
<dd></dd>
<b><code>has(key:string):boolean</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>toJSON():Object&lt;string, Object|boolean|Array|string|number|Uint8Array&gt;</code></b>
<dd>
Copies the <code>[key,value]</code> pairs of this YMap to a new Object.It
transforms all child types to JSON using their <code>toJSON</code> method.
</dd>
<b><code>forEach(function(key:string,value:object|boolean|Array|string|number|Uint8Array|Y.Type))</code></b>
<dd>
Execute the provided function once for every key-value pair.
</dd>
<b><code>[Symbol.Iterator]</code></b>
<dd>
Returns an Iterator of <code>[key, value]</code> pairs.
<pre>for (let [key, value] of ymap) { .. }</pre>
</dd>
<b><code>entries()</code></b>
<dd>
Returns an Iterator of <code>[key, value]</code> pairs.
</dd>
<b><code>values()</code></b>
<dd>
Returns an Iterator of all values.
</dd>
<b><code>keys()</code></b>
<dd>
Returns an Iterator of all keys.
</dd>
<b><code>observe(function(YMapEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
this type is modified. In the case this type is modified in the event listener,
the event listener will be called again after the current event listener returns.
</dd>
<b><code>unobserve(function(YMapEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
this type or any of its children is modified. In the case this type is modified
in the event listener, the event listener will be called again after the current
event listener returns. The event listener receives all Events created by itself
or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
<details>
<summary><b>Y.Text</b></summary>
<br>
<p>
A shareable type that is optimized for shared editing on text. It allows to
assign properties to ranges in the text. This makes it possible to implement
rich-text bindings to this type.
</p>
<p>
This type can also be transformed to the
<a href="https://quilljs.com/docs/delta">delta format</a>. Similarly the
YTextEvents compute changes as deltas.
</p>
<pre>const ytext = new Y.Text()</pre>
<dl>
<b><code>insert(index:number, content:string, [formattingAttributes:Object&lt;string,string&gt;])</code></b>
<dd>
Insert a string at <var>index</var> and assign formatting attributes to it.
<pre>ytext.insert(0, 'bold text', { bold: true })</pre>
</dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>format(index:number, length:number, formattingAttributes:Object&lt;string,string&gt;)</code></b>
<dd>Assign formatting attributes to a range in the text</dd>
<b><code>applyDelta(delta)</code></b>
<dd>See <a href="https://quilljs.com/docs/delta/">Quill Delta</a></dd>
<b><code>length:number</code></b>
<dd></dd>
<b><code>toString():string</code></b>
<dd>Transforms this type, without formatting options, into a string.</dd>
<b><code>toJSON():string</code></b>
<dd>See <code>toString</code></dd>
<b><code>toDelta():Delta</code></b>
<dd>
Transforms this type to a <a href="https://quilljs.com/docs/delta/">Quill Delta</a>
</dd>
<b><code>observe(function(YTextEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
this type is modified. In the case this type is modified in the event listener,
the event listener will be called again after the current event listener returns.
</dd>
<b><code>unobserve(function(YTextEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
this type or any of its children is modified. In the case this type is modified
in the event listener, the event listener will be called again after the current
event listener returns. The event listener receives all Events created by itself
or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
<details>
<summary><b>YXmlFragment</b></summary>
<br>
<p>
A container that holds an Array of Y.XmlElements.
</p>
<pre><code>const yxml = new Y.XmlFragment()</code></pre>
<dl>
<b><code>insert(index:number, content:Array&lt;Y.XmlElement|Y.XmlText&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>length:number</code></b>
<dd></dd>
<b><code>toArray():Array&lt;Y.XmlElement|Y.XmlText&gt;</code></b>
<dd>Copies the children to a new Array.</dd>
<b><code>toDOM():DocumentFragment</code></b>
<dd>Transforms this type and all children to new DOM elements.</dd>
<b><code>toString():string</code></b>
<dd>Get the XML serialization of all descendants.</dd>
<b><code>toJSON():string</code></b>
<dd>See <code>toString</code>.</dd>
<b><code>observe(function(YXmlEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
this type is modified. In the case this type is modified in the event listener,
the event listener will be called again after the current event listener returns.
</dd>
<b><code>unobserve(function(YXmlEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
this type or any of its children is modified. In the case this type is modified
in the event listener, the event listener will be called again after the current
event listener returns. The event listener receives all Events created by itself
or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
<details>
<summary><b>Y.XmlElement</b></summary>
<br>
<p>
A shareable type that represents an XML Element. It has a <code>nodeName</code>,
attributes, and a list of children. But it makes no effort to validate its
content and be actually XML compliant.
</p>
<pre><code>const yxml = new Y.XmlElement()</code></pre>
<dl>
<b><code>insert(index:number, content:Array&lt;Y.XmlElement|Y.XmlText&gt;)</code></b>
<dd></dd>
<b><code>delete(index:number, length:number)</code></b>
<dd></dd>
<b><code>get(index:number)</code></b>
<dd></dd>
<b><code>length:number</code></b>
<dd></dd>
<b><code>setAttribute(attributeName:string, attributeValue:string)</code></b>
<dd></dd>
<b><code>removeAttribute(attributeName:string)</code></b>
<dd></dd>
<b><code>getAttribute(attributeName:string):string</code></b>
<dd></dd>
<b><code>getAttributes(attributeName:string):Object&lt;string,string&gt;</code></b>
<dd></dd>
<b><code>toArray():Array&lt;Y.XmlElement|Y.XmlText&gt;</code></b>
<dd>Copies the children to a new Array.</dd>
<b><code>toDOM():Element</code></b>
<dd>Transforms this type and all children to a new DOM element.</dd>
<b><code>toString():string</code></b>
<dd>Get the XML serialization of all descendants.</dd>
<b><code>toJSON():string</code></b>
<dd>See <code>toString</code>.</dd>
<b><code>observe(function(YXmlEvent, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every
time this type is modified. In the case this type is modified in the event
listener, the event listener will be called again after the current event
listener returns.
</dd>
<b><code>unobserve(function(YXmlEvent, Transaction):void)</code></b>
<dd>
Removes an <code>observe</code> event listener from this type.
</dd>
<b><code>observeDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Adds an event listener to this type that will be called synchronously every time
this type or any of its children is modified. In the case this type is modified
in the event listener, the event listener will be called again after the current
event listener returns. The event listener receives all Events created by itself
or any of its children.
</dd>
<b><code>unobserveDeep(function(Array&lt;YEvent&gt;, Transaction):void)</code></b>
<dd>
Removes an <code>observeDeep</code> event listener from this type.
</dd>
</dl>
</details>
### Y.Doc
```js
const doc = new Y.Doc()
```
<dl>
<b><code>clientID</code></b>
<dd>A unique id that identifies this client. (readonly)</dd>
<b><code>transact(function(Transaction):void [, origin:any])</code></b>
<dd>
Every change on the shared document happens in a transaction. Observer calls and
the <code>update</code> event are called after each transaction. You should
<i>bundle</i> changes into a single transaction to reduce the amount of event
calls. I.e. <code>doc.transact(() => { yarray.insert(..); ymap.set(..) })</code>
triggers a single change event. <br>You can specify an optional <code>origin</code>
parameter that is stored on <code>transaction.origin</code> and
<code>on('update', (update, origin) => ..)</code>.
</dd>
<b><code>get(string, Y.[TypeClass]):[Type]</code></b>
<dd>Define a shared type.</dd>
<b><code>getArray(string):Y.Array</code></b>
<dd>Define a shared Y.Array type. Is equivalent to <code>y.get(string, Y.Array)</code>.</dd>
<b><code>getMap(string):Y.Map</code></b>
<dd>Define a shared Y.Map type. Is equivalent to <code>y.get(string, Y.Map)</code>.</dd>
<b><code>getXmlFragment(string):Y.XmlFragment</code></b>
<dd>Define a shared Y.XmlFragment type. Is equivalent to <code>y.get(string, Y.XmlFragment)</code>.</dd>
<b><code>on(string, function)</code></b>
<dd>Register an event listener on the shared type</dd>
<b><code>off(string, function)</code></b>
<dd>Unregister an event listener from the shared type</dd>
</dl>
#### Y.Doc Events
<dl>
<b><code>on('update', function(updateMessage:Uint8Array, origin:any, Y.Doc):void)</code></b>
<dd>
Listen to document updates. Document updates must be transmitted to all other
peers. You can apply document updates in any order and multiple times.
</dd>
<b><code>on('beforeTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
<dd>Emitted before each transaction.</dd>
<b><code>on('afterTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
<dd>Emitted after each transaction.</dd>
</dl>
### Document Updates
Changes on the shared document are encoded into *document updates*. Document
updates are *commutative* and *idempotent*. This means that they can be applied
in any order and multiple times.
#### Example: Listen to update events and apply them on remote client
```js
const doc1 = new Y.Doc()
const doc2 = new Y.Doc()
doc1.on('update', update => {
Y.applyUpdate(doc2, update)
})
doc2.on('update', update => {
Y.applyUpdate(doc1, update)
})
// All changes are also applied to the other document
doc1.getArray('myarray').insert(0, ['Hello doc2, you got this?'])
doc2.getArray('myarray').get(0) // => 'Hello doc2, you got this?'
```
Yjs internally maintains a [state vector](#State-Vector) that denotes the next
expected clock from each client. In a different interpretation it holds the
number of structs created by each client. When two clients sync, you can either
exchange the complete document structure or only the differences by sending the
state vector to compute the differences.
#### Example: Sync two clients by exchanging the complete document structure
```js
const state1 = Y.encodeStateAsUpdate(ydoc1)
const state2 = Y.encodeStateAsUpdate(ydoc2)
Y.applyUpdate(ydoc1, state2)
Y.applyUpdate(ydoc2, state1)
```
#### Example: Sync two clients by computing the differences
This example shows how to sync two clients with the minimal amount of exchanged
data by computing only the differences using the state vector of the remote
client. Syncing clients using the state vector requires another roundtrip, but
can safe a lot of bandwidth.
```js
const stateVector1 = Y.encodeStateVector(ydoc1)
const stateVector2 = Y.encodeStateVector(ydoc2)
const diff1 = Y.encodeStateAsUpdate(ydoc1, stateVector2)
const diff2 = Y.encodeStateAsUpdate(ydoc2, stateVector1)
Y.applyUpdate(ydoc1, diff2)
Y.applyUpdate(ydoc2, diff1)
```
<dl>
<b><code>Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])</code></b>
<dd>
Apply a document update on the shared document. Optionally you can specify
<code>transactionOrigin</code> that will be stored on
<code>transaction.origin</code>
and <code>ydoc.on('update', (update, origin) => ..)</code>.
</dd>
<b><code>Y.encodeStateAsUpdate(Y.Doc, [encodedTargetStateVector:Uint8Array]):Uint8Array</code></b>
<dd>
Encode the document state as a single update message that can be applied on the
remote document. Optionally specify the target state vector to only write the
differences to the update message.
</dd>
<b><code>Y.encodeStateVector(Y.Doc):Uint8Array</code></b>
<dd>Computes the state vector and encodes it into an Uint8Array.</dd>
</dl>
### Relative Positions
> This API is not stable yet
This feature is intended for managing selections / cursors. When working with
other users that manipulate the shared document, you can't trust that an index
position (an integer) will stay at the intended location. A *relative position*
is fixated to an element in the shared document and is not affected by remote
changes. I.e. given the document `"a|c"`, the relative position is attached to
`c`. When a remote user modifies the document by inserting a character before
the cursor, the cursor will stay attached to the character `c`. `insert(1,
'x')("a|c") = "ax|c"`. When the *relative position* is set to the end of the
document, it will stay attached to the end of the document.
#### Example: Transform to RelativePosition and back
```js
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const pos = Y.createAbsolutePositionFromRelativePosition(relPos, doc)
pos.type === ytext // => true
pos.index === 2 // => true
```
#### Example: Send relative position to remote client (json)
```js
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = JSON.stringify(relPos)
// send encodedRelPos to remote client..
const parsedRelPos = JSON.parse(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true
```
#### Example: Send relative position to remote client (Uint8Array)
```js
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = Y.encodeRelativePosition(relPos)
// send encodedRelPos to remote client..
const parsedRelPos = Y.decodeRelativePosition(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true
```
<dl>
<b><code>Y.createRelativePositionFromTypeIndex(Uint8Array|Y.Type, number)</code></b>
<dd></dd>
<b><code>Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc)</code></b>
<dd></dd>
<b><code>Y.encodeRelativePosition(RelativePosition):Uint8Array</code></b>
<dd></dd>
<b><code>Y.decodeRelativePosition(Uint8Array):RelativePosition</code></b>
<dd></dd>
</dl>
### Y.UndoManager
Yjs ships with an Undo/Redo manager for selective undo/redo of of changes on a
Yjs type. The changes can be optionally scoped to transaction origins.
```js
const ytext = doc.getArray('array')
const undoManager = new Y.UndoManager(ytext)
ytext.insert(0, 'abc')
undoManager.undo()
ytext.toString() // => ''
undoManager.redo()
ytext.toString() // => 'abc'
```
<dl>
<b><code>constructor(scope:Y.AbstractType|Array&lt;Y.AbstractType&gt;,
[trackedTransactionOrigins:Set&lt;any&gt;, [{captureTimeout: number}]])</code></b>
<dd>Accepts either single type as scope or an array of types.</dd>
<b><code>undo()</code></b>
<dd></dd>
<b><code>redo()</code></b>
<dd></dd>
<b><code>stopCapturing()</code></b>
<dd></dd>
<b>
<code>
on('stack-item-added', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
| 'redo' })
</code>
</b>
<dd>
Register an event that is called when a <code>StackItem</code> is added to the
undo- or the redo-stack.
</dd>
<b>
<code>
on('stack-item-popped', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
| 'redo' })
</code>
</b>
<dd>
Register an event that is called when a <code>StackItem</code> is popped from
the undo- or the redo-stack.
</dd>
</dl>
#### Example: Stop Capturing
UndoManager merges Undo-StackItems if they are created within time-gap
smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next
StackItem won't be merged.
```js
// without stopCapturing
ytext.insert(0, 'a')
ytext.insert(1, 'b')
um.undo()
ytext.toString() // => '' (note that 'ab' was removed)
// with stopCapturing
ytext.insert(0, 'a')
um.stopCapturing()
ytext.insert(0, 'b')
um.undo()
ytext.toString() // => 'a' (note that only 'b' was removed)
```
#### Example: Specify tracked origins
Every change on the shared document has an origin. If no origin was specified,
it defaults to `null`. By specifying `trackedTransactionOrigins` you can
selectively specify which changes should be tracked by `UndoManager`. The
UndoManager instance is always added to `trackedTransactionOrigins`.
```js
class CustomBinding {}
const ytext = doc.getArray('array')
const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding]))
ytext.insert(0, 'abc')
undoManager.undo()
ytext.toString() // => 'abc' (does not track because origin `null` and not part
// of `trackedTransactionOrigins`)
ytext.delete(0, 3) // revert change
doc.transact(() => {
ytext.insert(0, 'abc')
}, 42)
undoManager.undo()
ytext.toString() // => '' (tracked because origin is an instance of `trackedTransactionorigins`)
doc.transact(() => {
ytext.insert(0, 'abc')
}, 41)
undoManager.undo()
ytext.toString() // => '' (not tracked because 41 is not an instance of
// `trackedTransactionorigins`)
ytext.delete(0, 3) // revert change
doc.transact(() => {
ytext.insert(0, 'abc')
}, new CustomBinding())
undoManager.undo()
ytext.toString() // => '' (tracked because origin is a `CustomBinding` and
// `CustomBinding` is in `trackedTransactionorigins`)
```
#### Example: Add additional information to the StackItems
When undoing or redoing a previous action, it is often expected to restore
additional meta information like the cursor location or the view on the
document. You can assign meta-information to Undo-/Redo-StackItems.
```js
const ytext = doc.getArray('array')
const undoManager = new Y.UndoManager(ytext, new Set([42, CustomBinding]))
undoManager.on('stack-item-added', event => {
// save the current cursor location on the stack-item
event.stackItem.meta.set('cursor-location', getRelativeCursorLocation())
})
undoManager.on('stack-item-popped', event => {
// restore the current cursor location on the stack-item
restoreCursorLocation(event.stackItem.meta.get('cursor-location'))
})
```
## Miscellaneous
### Typescript Declarations
Yjs has type descriptions. But until [this
ticket](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, this is
how you can make use of Yjs type declarations.
```json
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
},
"maxNodeModuleJsDepth": 5
}
```
## Yjs CRDT Algorithm
*Conflict-free replicated data types* (CRDT) for collaborative editing are an
alternative approach to *operational transformation* (OT). A very simple
differenciation between the two approaches is that OT attempts to transform
index positions to ensure convergence (all clients end up with the same
content), while CRDTs use mathematical models that usually do not involve index
transformations, like linked lists. OT is currently the de-facto standard for
shared editing on text. OT approaches that support shared editing without a
central source of truth (a central server) require too much bookkeeping to be
viable in practice. CRDTs are better suited for distributed systems, provide
additional guarantees that the document can be synced with remote clients, and
do not require a central source of truth.
Yjs implements a modified version of the algorithm described in [this
paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types).
I will eventually publish a paper that describes why this approach works so well
in practice. Note: Since operations make up the document structure, we prefer
the term *struct* now.
CRDTs suitable for shared text editing suffer from the fact that they only grow
in size. There are CRDTs that do not grow in size, but they do not have the
characteristics that are benificial for shared text editing (like intention
preservation). Yjs implements many improvements to the original algorithm that
diminish the trade-off that the document only grows in size. We can't garbage
collect deleted structs (tombstones) while ensuring a unique order of the
structs. But we can 1. merge preceeding structs into a single struct to reduce
the amount of meta information, 2. we can delete content from the struct if it
is deleted, and 3. we can garbage collect tombstones if we don't care about the
order of the structs anymore (e.g. if the parent was deleted).
**Examples:**
1. If a user inserts elements in sequence, the struct will be merged into a
single struct. E.g. `array.insert(0, ['a']), array.insert(0, ['b']);` is
first represented as two structs (`[{id: {client, clock: 0}, content: 'a'},
{id: {client, clock: 1}, content: 'b'}`) and then merged into a single
struct: `[{id: {client, clock: 0}, content: 'ab'}]`.
2. When a struct that contains content (e.g. `ItemString`) is deleted, the
struct will be replaced with an `ItemDeleted` that does not contain content
anymore.
3. When a type is deleted, all child elements are transformed to `GC` structs. A
`GC` struct only denotes the existence of a struct and that it is deleted.
`GC` structs can always be merged with other `GC` structs if the id's are
adjacent.
Especially when working on structured content (e.g. shared editing on
ProseMirror), these improvements yield very good results when
[benchmarking](https://github.com/dmonad/crdt-benchmarks) random document edits.
In practice they show even better results, because users usually edit text in
sequence, resulting in structs that can easily be merged. The benchmarks show
that even in the worst case scenario that a user edits text from right to left,
Yjs achieves good performance even for huge documents.
### State Vector
Yjs has the ability to exchange only the differences when syncing two clients.
We use lamport timestamps to identify structs and to track in which order a
client created them. Each struct has an `struct.id = { client: number, clock:
number}` that uniquely identifies a struct. We define the next expected `clock`
by each client as the *state vector*. This data structure is similar to the
[version vectors](https://en.wikipedia.org/wiki/Version_vector) data structure.
But we use state vectors only to describe the state of the local document, so we
can compute the missing struct of the remote client. We do not use it to track
causality.
## License and Author
Yjs and all related projects are [**MIT licensed**](./LICENSE).
Yjs is based on my research as a student at the [RWTH
i5](http://dbis.rwth-aachen.de/). Now I am working on Yjs in my spare time.
Fund this project by donating on [Patreon](https://www.patreon.com/dmonad) or
hiring [me](https://github.com/dmonad) for professional support.

194
package-lock.json generated
View File

@ -1,13 +1,13 @@
{
"name": "yjs",
"version": "13.0.0-94",
"version": "13.0.0-102",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@babel/parser": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.4.5.tgz",
"integrity": "sha512-9mUqkL1FF5T7f0WDFfAoDdiMVPWsdD1gZYzSnaXsxUCUqzuch/8of9G3VUSNiZmMBoRxT3neyVsqeiL/ZPcjew==",
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.5.tgz",
"integrity": "sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==",
"dev": true
},
"@types/estree": {
@ -42,9 +42,9 @@
}
},
"acorn": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.1.1.tgz",
"integrity": "sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.0.0.tgz",
"integrity": "sha512-PaF/MduxijYYt7unVGRuds1vBC9bFxbNf+VWqhOClfdgy7RlVkQqt610ig1/yxTgsDIfW1cWDel5EBbOy3jdtQ==",
"dev": true
},
"acorn-jsx": {
@ -431,12 +431,12 @@
"dev": true
},
"catharsis": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.8.10.tgz",
"integrity": "sha512-l2OUaz/3PU3MZylspVFJvwHCVfWyvcduPq4lv3AzZ2pJzZCo7kNKFNyatwujD7XgvGkNAE/Jhhbh2uARNwNkfw==",
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.8.11.tgz",
"integrity": "sha512-a+xUyMV7hD1BrDQA/3iPV7oc+6W26BgVJO05PGEoatMyIuPScQKsde6i3YorWX1qs+AZjnJ18NqdKoCtKiNh1g==",
"dev": true,
"requires": {
"lodash": "^4.17.11"
"lodash": "^4.17.14"
}
},
"chalk": {
@ -1490,7 +1490,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -1511,12 +1512,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -1531,17 +1534,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -1658,7 +1664,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -1670,6 +1677,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -1684,6 +1692,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -1691,12 +1700,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -1715,6 +1726,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -1795,7 +1807,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -1807,6 +1820,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -1892,7 +1906,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -1928,6 +1943,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -1947,6 +1963,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -1990,12 +2007,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},
@ -2495,22 +2514,22 @@
}
},
"jsdoc": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.2.tgz",
"integrity": "sha512-S2vzg99C5+gb7FWlrK4TVdyzVPGGkdvpDkCEJH1JABi2PKzPeLu5/zZffcJUifgWUJqXWl41Hoc+MmuM2GukIg==",
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.3.tgz",
"integrity": "sha512-Yf1ZKA3r9nvtMWHO1kEuMZTlHOF8uoQ0vyo5eH7SQy5YeIiHM+B0DgKnn+X6y6KDYZcF7G2SPkKF+JORCXWE/A==",
"dev": true,
"requires": {
"@babel/parser": "^7.4.4",
"bluebird": "^3.5.4",
"catharsis": "^0.8.10",
"catharsis": "^0.8.11",
"escape-string-regexp": "^2.0.0",
"js2xmlparser": "^4.0.0",
"klaw": "^3.0.0",
"markdown-it": "^8.4.2",
"markdown-it-anchor": "^5.0.2",
"marked": "^0.6.2",
"marked": "^0.7.0",
"mkdirp": "^0.5.1",
"requizzle": "^0.2.2",
"requizzle": "^0.2.3",
"strip-json-comments": "^3.0.1",
"taffydb": "2.6.2",
"underscore": "~1.9.1"
@ -2577,14 +2596,14 @@
}
},
"lib0": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.0.5.tgz",
"integrity": "sha512-3ElV6/t5Lv0Eczlnh/05q+Uq3RxQ/Q0zdN6LVtaUERQIDDZsP/CUXEGLsV8KZTgZwVFNCPGXNWYE+3WTOo+SHw=="
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.1.1.tgz",
"integrity": "sha512-ghjoI4xL/xzVR1fRLYEOnJjYMguoI2dnDUf5HYOpTfD6R5GPKLml6xNKl4ZfBVmczkIOQPNthhukp6nlgbmDLw=="
},
"linkify-it": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.1.0.tgz",
"integrity": "sha512-4REs8/062kV2DSHxNfq5183zrqXMl7WP0WzABH9IeJI+NLm429FgE1PDecltYfnOoFDFlZGh2T8PfZn0r+GTRg==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"dev": true,
"requires": {
"uc.micro": "^1.0.1"
@ -2634,9 +2653,9 @@
}
},
"lodash": {
"version": "4.17.11",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==",
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
"dev": true
},
"lodash.assignin": {
@ -2682,9 +2701,9 @@
"dev": true
},
"lodash.merge": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz",
"integrity": "sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ==",
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"lodash.pick": {
@ -2765,15 +2784,15 @@
}
},
"markdown-it-anchor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-5.1.0.tgz",
"integrity": "sha512-wJOmyXzDUxI8iuowEsaQAKMQBButhSw8j64SpgcaL75QZYC/OSZV66Fnr50lfMLYNGtV0rJdw2fmLwXCT6T+bw==",
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-5.2.4.tgz",
"integrity": "sha512-n8zCGjxA3T+Mx1pG8HEgbJbkB8JFUuRkeTZQuIM8iPY6oQ8sWOPRZJDFC9a/pNg2QkHEjjGkhBEl/RSyzaDZ3A==",
"dev": true
},
"marked": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.6.2.tgz",
"integrity": "sha512-LqxwVH3P/rqKX4EKGz7+c2G9r98WeM/SW34ybhgNGhUQNKtf1GmmSkJ6cDGJ/t6tiyae49qRkpyTw2B9HOrgUA==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz",
"integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==",
"dev": true
},
"mdurl": {
@ -2846,9 +2865,9 @@
"dev": true
},
"mixin-deep": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz",
"integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
"integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
"dev": true,
"requires": {
"for-in": "^1.0.2",
@ -3418,12 +3437,12 @@
}
},
"requizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.2.tgz",
"integrity": "sha512-oJ6y7JcUJkblRGhMByGNcszeLgU0qDxNKFCiUZR1XyzHyVsev+Mxb1tyygxLd1ORsKee1SA5BInFdUwY64GE/A==",
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.3.tgz",
"integrity": "sha512-YanoyJjykPxGHii0fZP0uUPEXpvqfBDxWV7s6GKAiiOsiqhX6vHNyW3Qzdmqp/iq/ExbhaGbVrjB4ruEVSM4GQ==",
"dev": true,
"requires": {
"lodash": "^4.17.11"
"lodash": "^4.17.14"
}
},
"resolve": {
@ -3473,14 +3492,22 @@
}
},
"rollup": {
"version": "1.12.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-1.12.4.tgz",
"integrity": "sha512-sHg0F05oTMJzM592MWU8irsPx8LIFMKSCnEkcp6vp/gnj+oJ9GJEBW9hl8jUqy2L6Q2uUxFzPgvoExLbfuSODA==",
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-1.20.3.tgz",
"integrity": "sha512-/OMCkY0c6E8tleeVm4vQVDz24CkVgvueK3r8zTYu2AQNpjrcaPwO9hE+pWj5LTFrvvkaxt4MYIp2zha4y0lRvg==",
"dev": true,
"requires": {
"@types/estree": "0.0.39",
"@types/node": "^12.0.2",
"acorn": "^6.1.1"
"@types/node": "^12.7.2",
"acorn": "^7.0.0"
},
"dependencies": {
"@types/node": {
"version": "12.7.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.4.tgz",
"integrity": "sha512-W0+n1Y+gK/8G2P/piTkBBN38Qc5Q1ZSO6B5H3QmPCUewaiXOo2GCAWZ4ElZCcNhjJuBSUSLGFUJnmlCn5+nxOQ==",
"dev": true
}
}
},
"rollup-cli": {
@ -3629,9 +3656,9 @@
}
},
"set-value": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz",
"integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
"integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
"dev": true,
"requires": {
"extend-shallow": "^2.0.1",
@ -4139,9 +4166,9 @@
"dev": true
},
"typescript": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.5.tgz",
"integrity": "sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw==",
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.2.tgz",
"integrity": "sha512-lmQ4L+J6mnu3xweP8+rOrUwzmN+MRAj7TgtJtDaXE5PMyX2kCrklhg3rvOsOIfNeAWMQWO2F1GPc1kMD2vLAfw==",
"dev": true
},
"uc.micro": {
@ -4157,38 +4184,15 @@
"dev": true
},
"union-value": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz",
"integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
"integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
"dev": true,
"requires": {
"arr-union": "^3.1.0",
"get-value": "^2.0.6",
"is-extendable": "^0.1.1",
"set-value": "^0.4.3"
},
"dependencies": {
"extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
"dev": true,
"requires": {
"is-extendable": "^0.1.0"
}
},
"set-value": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz",
"integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=",
"dev": true,
"requires": {
"extend-shallow": "^2.0.1",
"is-extendable": "^0.1.1",
"is-plain-object": "^2.0.1",
"to-object-path": "^0.3.0"
}
}
"set-value": "^2.0.1"
}
},
"uniq": {
@ -4366,6 +4370,14 @@
"dev": true,
"requires": {
"lib0": "0.0.5"
},
"dependencies": {
"lib0": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.0.5.tgz",
"integrity": "sha512-3ElV6/t5Lv0Eczlnh/05q+Uq3RxQ/Q0zdN6LVtaUERQIDDZsP/CUXEGLsV8KZTgZwVFNCPGXNWYE+3WTOo+SHw==",
"dev": true
}
}
},
"yallist": {

View File

@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.0.0-94",
"version": "13.0.0-102",
"description": "Shared Editing Library",
"main": "./dist/yjs.js",
"module": "./dist/yjs.mjs",
@ -10,8 +10,8 @@
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000",
"dist": "rm -rf dist && rollup -c",
"watch": "rollup -wc",
"lint": "markdownlint README.v13.md && standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.md --package ./package.json || true",
"lint": "markdownlint README.md && standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true",
"serve-docs": "npm run docs && serve ./docs/",
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.js --repitition-time 1000",
"postversion": "git push && git push --tags",
@ -38,31 +38,31 @@
},
"repository": {
"type": "git",
"url": "https://github.com/y-js/yjs.git"
"url": "https://github.com/yjs/yjs.git"
},
"keywords": [
"crdt"
],
"author": "Kevin Jahns",
"email": "kevin.jahns@rwth-aachen.de",
"email": "kevin.jahns@protonmail.com",
"license": "MIT",
"bugs": {
"url": "https://github.com/y-js/yjs/issues"
"url": "https://github.com/yjs/yjs/issues"
},
"homepage": "http://y-js.org",
"homepage": "https://yjs.dev",
"dependencies": {
"lib0": "0.0.5"
"lib0": "^0.1.1"
},
"devDependencies": {
"concurrently": "^3.6.1",
"jsdoc": "^3.6.2",
"jsdoc": "^3.6.3",
"live-server": "^1.2.1",
"rollup": "^1.11.3",
"rollup": "^1.20.3",
"rollup-cli": "^1.0.9",
"rollup-plugin-node-resolve": "^4.2.4",
"standard": "^11.0.1",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^3.4.5",
"typescript": "^3.6.2",
"y-protocols": "0.0.6"
}
}

View File

@ -21,6 +21,7 @@ export {
ContentEmbed,
ContentFormat,
ContentJSON,
ContentAny,
ContentString,
ContentType,
AbstractType,
@ -36,6 +37,11 @@ export {
compareIDs,
getState,
Snapshot,
createSnapshot,
createDeleteSet,
createDeleteSetFromStructStore,
snapshot,
emptySnapshot,
findRootTypeKey,
typeListToArraySnapshot,
typeMapGetSnapshot,
@ -43,5 +49,10 @@ export {
applyUpdate,
encodeStateAsUpdate,
encodeStateVector,
UndoManager
UndoManager,
decodeSnapshot,
encodeSnapshot,
isDeleted,
equalSnapshots,
PermanentUserData // @TODO experimental
} from './internals.js'

View File

@ -1,13 +1,16 @@
export * from './utils/DeleteSet.js'
export * from './utils/Doc.js'
export * from './utils/encoding.js'
export * from './utils/EventHandler.js'
export * from './utils/ID.js'
export * from './utils/isParentOf.js'
export * from './utils/PermanentUserData.js'
export * from './utils/RelativePosition.js'
export * from './utils/Snapshot.js'
export * from './utils/StructStore.js'
export * from './utils/Transaction.js'
export * from './utils/UndoManager.js'
export * from './utils/Doc.js'
export * from './utils/YEvent.js'
export * from './types/AbstractType.js'
@ -27,8 +30,7 @@ export * from './structs/ContentDeleted.js'
export * from './structs/ContentEmbed.js'
export * from './structs/ContentFormat.js'
export * from './structs/ContentJSON.js'
export * from './structs/ContentAny.js'
export * from './structs/ContentString.js'
export * from './structs/ContentType.js'
export * from './structs/Item.js'
export * from './utils/encoding.js'

108
src/structs/ContentAny.js Normal file
View File

@ -0,0 +1,108 @@
import {
Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
/**
* @private
*/
export class ContentAny {
/**
* @param {Array<any>} arr
*/
constructor (arr) {
/**
* @type {Array<any>}
*/
this.arr = arr
}
/**
* @return {number}
*/
getLength () {
return this.arr.length
}
/**
* @return {Array<any>}
*/
getContent () {
return this.arr
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentAny}
*/
copy () {
return new ContentAny(this.arr)
}
/**
* @param {number} offset
* @return {ContentAny}
*/
splice (offset) {
const right = new ContentAny(this.arr.slice(offset))
this.arr = this.arr.slice(0, offset)
return right
}
/**
* @param {ContentAny} right
* @return {boolean}
*/
mergeWith (right) {
this.arr = this.arr.concat(right.arr)
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {encoding.Encoder} encoder
* @param {number} offset
*/
write (encoder, offset) {
const len = this.arr.length
encoding.writeVarUint(encoder, len - offset)
for (let i = offset; i < len; i++) {
const c = this.arr[i]
encoding.writeAny(encoder, c)
}
}
/**
* @return {number}
*/
getRef () {
return 8
}
}
/**
* @private
*
* @param {decoding.Decoder} decoder
* @return {ContentAny}
*/
export const readContentAny = decoder => {
const len = decoding.readVarUint(decoder)
const cs = []
for (let i = 0; i < len; i++) {
cs.push(decoding.readAny(decoder))
}
return new ContentAny(cs)
}

View File

@ -18,6 +18,7 @@ import {
readContentDeleted,
readContentBinary,
readContentJSON,
readContentAny,
readContentString,
readContentEmbed,
readContentFormat,
@ -34,6 +35,8 @@ import * as set from 'lib0/set.js'
import * as binary from 'lib0/binary.js'
/**
* @todo This should return several items
*
* @param {StructStore} store
* @param {ID} id
* @return {{item:Item, diff:number}}
@ -134,7 +137,7 @@ export const splitItem = (transaction, leftItem, diff) => {
*/
export const redoItem = (transaction, item, redoitems) => {
if (item.redone !== null) {
return getItemCleanStart(transaction, transaction.doc.store, item.redone)
return getItemCleanStart(transaction, item.redone)
}
let parentItem = item.parent._item
/**
@ -174,7 +177,7 @@ export const redoItem = (transaction, item, redoitems) => {
}
if (parentItem !== null && parentItem.redone !== null) {
while (parentItem.redone !== null) {
parentItem = getItemCleanStart(transaction, transaction.doc.store, parentItem.redone)
parentItem = getItemCleanStart(transaction, parentItem.redone)
}
// find next cloned_redo items
while (left !== null) {
@ -184,7 +187,7 @@ export const redoItem = (transaction, item, redoitems) => {
let leftTrace = left
// trace redone until parent matches
while (leftTrace !== null && leftTrace.parent._item !== parentItem) {
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, transaction.doc.store, leftTrace.redone)
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
}
if (leftTrace !== null && leftTrace.parent._item === parentItem) {
left = leftTrace
@ -199,7 +202,7 @@ export const redoItem = (transaction, item, redoitems) => {
let rightTrace = right
// trace redone until parent matches
while (rightTrace !== null && rightTrace.parent._item !== parentItem) {
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, transaction.doc.store, rightTrace.redone)
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
}
if (rightTrace !== null && rightTrace.parent._item === parentItem) {
right = rightTrace
@ -561,7 +564,8 @@ export const contentRefs = [
readContentString,
readContentEmbed,
readContentFormat,
readContentType
readContentType,
readContentAny
]
/**
@ -724,7 +728,7 @@ export class ItemRef extends AbstractStructRef {
}
const left = this.left === null ? null : getItemCleanEnd(transaction, store, this.left)
const right = this.right === null ? null : getItemCleanStart(transaction, store, this.right)
const right = this.right === null ? null : getItemCleanStart(transaction, this.right)
let parent = null
let parentSub = this.parentSub
if (this.parent !== null) {

View File

@ -7,7 +7,7 @@ import {
nextID,
isVisible,
ContentType,
ContentJSON,
ContentAny,
ContentBinary,
createID,
getItemCleanStart,
@ -30,7 +30,7 @@ import * as encoding from 'lib0/encoding.js' // eslint-disable-line
* @param {EventType} event
*/
export const callTypeObservers = (type, transaction, event) => {
callEventHandlerListeners(type._eH, event, transaction)
const changedType = type
const changedParentTypes = transaction.changedParentTypes
while (true) {
// @ts-ignore
@ -40,6 +40,7 @@ export const callTypeObservers = (type, transaction, event) => {
}
type = type._item.parent
}
callEventHandlerListeners(changedType._eH, event, transaction)
}
/**
@ -374,7 +375,7 @@ export const typeListInsertGenericsAfter = (transaction, parent, referenceItem,
let jsonContent = []
const packJsonContent = () => {
if (jsonContent.length > 0) {
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentJSON(jsonContent))
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentAny(jsonContent))
left.integrate(transaction)
jsonContent = []
}
@ -428,7 +429,7 @@ export const typeListInsertGenerics = (transaction, parent, index, content) => {
if (index <= n.length) {
if (index < n.length) {
// insert in-between
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + index))
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
break
}
@ -454,7 +455,7 @@ export const typeListDelete = (transaction, parent, index, length) => {
for (; n !== null && index > 0; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + index))
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
index -= n.length
}
@ -463,7 +464,7 @@ export const typeListDelete = (transaction, parent, index, length) => {
while (length > 0 && n !== null) {
if (!n.deleted) {
if (length < n.length) {
getItemCleanStart(transaction, transaction.doc.store, createID(n.id.client, n.id.clock + length))
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length))
}
n.delete(transaction)
length -= n.length
@ -503,7 +504,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
const left = parent._map.get(key) || null
let content
if (value == null) {
content = new ContentJSON([value])
content = new ContentAny([value])
} else {
switch (value.constructor) {
case Number:
@ -511,7 +512,7 @@ export const typeMapSet = (transaction, parent, key, value) => {
case Boolean:
case Array:
case String:
content = new ContentJSON([value])
content = new ContentAny([value])
break
case Uint8Array:
content = new ContentBinary(value)
@ -584,7 +585,7 @@ export const typeMapHas = (parent, key) => {
*/
export const typeMapGetSnapshot = (parent, key, snapshot) => {
let v = parent._map.get(key) || null
while (v !== null && (!snapshot.sm.has(v.id.client) || v.id.clock >= (snapshot.sm.get(v.id.client) || 0))) {
while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) {
v = v.left
}
return v !== null && isVisible(v, snapshot) ? v.content.getContent()[v.length - 1] : undefined

View File

@ -16,11 +16,20 @@ import {
ContentEmbed,
ContentFormat,
ContentString,
Doc, Item, Snapshot, StructStore, Transaction // eslint-disable-line
splitSnapshotAffectedStructs,
ID, Doc, Item, Snapshot, Transaction // eslint-disable-line
} from '../internals.js'
import * as decoding from 'lib0/decoding.js' // eslint-disable-line
import * as encoding from 'lib0/encoding.js'
import * as object from 'lib0/object.js'
/**
* @param {any} a
* @param {any} b
* @return {boolean}
*/
const equalAttrs = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b))
export class ItemListPosition {
/**
@ -59,7 +68,6 @@ export class ItemInsertionResult extends ItemListPosition {
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {Map<string,any>} currentAttributes
* @param {Item|null} left
* @param {Item|null} right
@ -69,7 +77,7 @@ export class ItemInsertionResult extends ItemListPosition {
* @private
* @function
*/
const findNextPosition = (transaction, store, currentAttributes, left, right, count) => {
const findNextPosition = (transaction, currentAttributes, left, right, count) => {
while (right !== null && count > 0) {
switch (right.content.constructor) {
case ContentEmbed:
@ -77,7 +85,7 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
if (!right.deleted) {
if (count < right.length) {
// split right
getItemCleanStart(transaction, store, createID(right.id.client, right.id.clock + count))
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + count))
}
count -= right.length
}
@ -96,7 +104,6 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @param {AbstractType<any>} parent
* @param {number} index
* @return {ItemTextListPosition}
@ -104,11 +111,11 @@ const findNextPosition = (transaction, store, currentAttributes, left, right, co
* @private
* @function
*/
const findPosition = (transaction, store, parent, index) => {
const findPosition = (transaction, parent, index) => {
let currentAttributes = new Map()
let left = null
let right = parent._start
return findNextPosition(transaction, store, currentAttributes, left, right, index)
return findNextPosition(transaction, currentAttributes, left, right, index)
}
/**
@ -130,7 +137,7 @@ const insertNegatedAttributes = (transaction, parent, left, right, negatedAttrib
right !== null && (
right.deleted === true || (
right.content.constructor === ContentFormat &&
(negatedAttributes.get(/** @type {ContentFormat} */ (right.content).key) === /** @type {ContentFormat} */ (right.content).value)
equalAttrs(negatedAttributes.get(/** @type {ContentFormat} */ (right.content).key), /** @type {ContentFormat} */ (right.content).value)
)
)
) {
@ -180,7 +187,7 @@ const minimizeAttributeChanges = (left, right, currentAttributes, attributes) =>
break
} else if (right.deleted) {
// continue
} else if (right.content.constructor === ContentFormat && (attributes[(/** @type {ContentFormat} */ (right.content)).key] || null) === /** @type {ContentFormat} */ (right.content).value) {
} else if (right.content.constructor === ContentFormat && equalAttrs(attributes[(/** @type {ContentFormat} */ (right.content)).key] || null, /** @type {ContentFormat} */ (right.content).value)) {
// found a format, update currentAttributes and continue
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (right.content))
} else {
@ -210,7 +217,7 @@ const insertAttributes = (transaction, parent, left, right, currentAttributes, a
for (let key in attributes) {
const val = attributes[key]
const currentVal = currentAttributes.get(key) || null
if (currentVal !== val) {
if (!equalAttrs(currentVal, val)) {
// save negated attribute (set null if currentVal undefined)
negatedAttributes.set(key, currentVal)
left = new Item(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, new ContentFormat(key, val))
@ -272,13 +279,13 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
// iterate until first non-format or null is found
// delete all formats with attributes[format.key] != null
while (length > 0 && right !== null) {
if (right.deleted === false) {
if (!right.deleted) {
switch (right.content.constructor) {
case ContentFormat:
const { key, value } = /** @type {ContentFormat} */ (right.content)
const attr = attributes[key]
if (attr !== undefined) {
if (attr === value) {
if (equalAttrs(attr, value)) {
negatedAttributes.delete(key)
} else {
negatedAttributes.set(key, value)
@ -290,7 +297,7 @@ const formatText = (transaction, parent, left, right, currentAttributes, length,
case ContentEmbed:
case ContentString:
if (length < right.length) {
getItemCleanStart(transaction, transaction.doc.store, createID(right.id.client, right.id.clock + length))
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
}
length -= right.length
break
@ -334,7 +341,7 @@ const deleteText = (transaction, left, right, currentAttributes, length) => {
case ContentEmbed:
case ContentString:
if (length < right.length) {
getItemCleanStart(transaction, transaction.doc.store, createID(right.id.client, right.id.clock + length))
getItemCleanStart(transaction, createID(right.id.client, right.id.clock + length))
}
length -= right.length
right.delete(transaction)
@ -516,11 +523,11 @@ export class YTextEvent extends YEvent {
if (this.adds(item)) {
if (!this.deletes(item)) {
const curVal = currentAttributes.get(key) || null
if (curVal !== value) {
if (!equalAttrs(curVal, value)) {
if (action === 'retain') {
addOp()
}
if (value === (oldAttributes.get(key) || null)) {
if (equalAttrs(value, (oldAttributes.get(key) || null))) {
delete attributes[key]
} else {
attributes[key] = value
@ -532,7 +539,7 @@ export class YTextEvent extends YEvent {
} else if (this.deletes(item)) {
oldAttributes.set(key, value)
const curVal = currentAttributes.get(key) || null
if (curVal !== value) {
if (!equalAttrs(curVal, value)) {
if (action === 'retain') {
addOp()
}
@ -542,7 +549,7 @@ export class YTextEvent extends YEvent {
oldAttributes.set(key, value)
const attr = attributes[key]
if (attr !== undefined) {
if (attr !== value) {
if (!equalAttrs(attr, value)) {
if (action === 'retain') {
addOp()
}
@ -705,16 +712,18 @@ export class YText extends AbstractType {
*
* @param {Snapshot} [snapshot]
* @param {Snapshot} [prevSnapshot]
* @param {function('removed' | 'added', ID):any} [computeYChange]
* @return {any} The Delta representation of this type.
*
* @public
*/
toDelta (snapshot, prevSnapshot) {
toDelta (snapshot, prevSnapshot, computeYChange) {
/**
* @type{Array<any>}
*/
const ops = []
const currentAttributes = new Map()
const doc = /** @type {Doc} */ (this.doc)
let str = ''
let n = this._start
function packStr () {
@ -740,42 +749,54 @@ export class YText extends AbstractType {
str = ''
}
}
while (n !== null) {
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
switch (n.content.constructor) {
case ContentString:
const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') {
packStr()
currentAttributes.set('ychange', { user: n.id.client, state: 'removed' })
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') {
packStr()
currentAttributes.set('ychange', { user: n.id.client, state: 'added' })
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
str += /** @type {ContentString} */ (n.content).str
break
case ContentEmbed:
packStr()
ops.push({
insert: /** @type {ContentEmbed} */ (n.content).embed
})
break
case ContentFormat:
packStr()
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
break
}
// snapshots are merged again after the transaction, so we need to keep the
// transalive until we are done
transact(doc, transaction => {
if (snapshot) {
splitSnapshotAffectedStructs(transaction, snapshot)
}
n = n.right
}
packStr()
if (prevSnapshot) {
splitSnapshotAffectedStructs(transaction, prevSnapshot)
}
while (n !== null) {
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
switch (n.content.constructor) {
case ContentString:
const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
}
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') {
packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
}
} else if (cur !== undefined) {
packStr()
currentAttributes.delete('ychange')
}
str += /** @type {ContentString} */ (n.content).str
break
case ContentEmbed:
packStr()
ops.push({
insert: /** @type {ContentEmbed} */ (n.content).embed
})
break
case ContentFormat:
if (isVisible(n, snapshot)) {
packStr()
updateCurrentAttributes(currentAttributes, /** @type {ContentFormat} */ (n.content))
}
break
}
}
n = n.right
}
packStr()
}, splitSnapshotAffectedStructs)
return ops
}
@ -784,19 +805,23 @@ export class YText extends AbstractType {
*
* @param {number} index The index at which to start inserting.
* @param {String} text The text to insert at the specified position.
* @param {TextAttributes} attributes Optionally define some formatting
* @param {TextAttributes} [attributes] Optionally define some formatting
* information to apply on the inserted
* Text.
* @public
*/
insert (index, text, attributes = {}) {
insert (index, text, attributes) {
if (text.length <= 0) {
return
}
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
if (!attributes) {
attributes = {}
currentAttributes.forEach((v, k) => { attributes[k] = v })
}
insertText(transaction, this, left, right, currentAttributes, text, attributes)
})
} else {
@ -821,7 +846,7 @@ export class YText extends AbstractType {
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
insertText(transaction, this, left, right, currentAttributes, embed, attributes)
})
} else {
@ -844,7 +869,7 @@ export class YText extends AbstractType {
const y = this.doc
if (y !== null) {
transact(y, transaction => {
const { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
const { left, right, currentAttributes } = findPosition(transaction, this, index)
deleteText(transaction, left, right, currentAttributes, length)
})
} else {
@ -866,7 +891,7 @@ export class YText extends AbstractType {
const y = this.doc
if (y !== null) {
transact(y, transaction => {
let { left, right, currentAttributes } = findPosition(transaction, y.store, this, index)
let { left, right, currentAttributes } = findPosition(transaction, this, index)
if (right === null) {
return
}

View File

@ -151,6 +151,10 @@ export class YXmlFragment extends AbstractType {
return new YXmlFragment()
}
get length () {
return this._prelimContent === null ? this._length : this._prelimContent.length
}
/**
* Create a subtree of childNodes.
*

View File

@ -56,7 +56,7 @@ export class YXmlText extends YText {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[i]
const attr = node.attrs[j]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'

View File

@ -8,6 +8,7 @@ import {
Item, GC, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as array from 'lib0/array.js'
import * as math from 'lib0/math.js'
import * as map from 'lib0/map.js'
import * as encoding from 'lib0/encoding.js'
@ -52,14 +53,13 @@ export class DeleteSet {
*
* @param {Transaction} transaction
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(GC|Item):void} f
*
* @function
*/
export const iterateDeletedStructs = (transaction, ds, store, f) =>
export const iterateDeletedStructs = (transaction, ds, f) =>
ds.clients.forEach((deletes, clientid) => {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(clientid))
const structs = /** @type {Array<GC|Item>} */ (transaction.doc.store.clients.get(clientid))
for (let i = 0; i < deletes.length; i++) {
const del = deletes[i]
iterateStructs(transaction, structs, del.clock, del.len, f)
@ -137,22 +137,27 @@ export const sortAndMergeDeleteSet = ds => {
}
/**
* @param {DeleteSet} ds1
* @param {DeleteSet} ds2
* @param {Array<DeleteSet>} dss
* @return {DeleteSet} A fresh DeleteSet
*/
export const mergeDeleteSets = (ds1, ds2) => {
export const mergeDeleteSets = dss => {
const merged = new DeleteSet()
// Write all keys from ds1 to merged. If ds2 has the same key, combine the sets.
ds1.clients.forEach((dels1, client) =>
merged.clients.set(client, dels1.concat(ds2.clients.get(client) || []))
)
// Write all missing keys from ds2 to merged.
ds2.clients.forEach((dels2, client) => {
if (!merged.clients.has(client)) {
merged.clients.set(client, dels2)
}
})
for (let dssI = 0; dssI < dss.length; dssI++) {
dss[dssI].clients.forEach((delsLeft, client) => {
if (!merged.clients.has(client)) {
// Write all missing keys from current ds and all following.
// If merged already contains `client` current ds has already been added.
/**
* @type {Array<DeleteItem>}
*/
const dels = delsLeft.slice()
for (let i = dssI + 1; i < dss.length; i++) {
array.appendTo(dels, dss[i].clients.get(client) || [])
}
merged.clients.set(client, dels)
}
})
}
sortAndMergeDeleteSet(merged)
return merged
}
@ -169,6 +174,8 @@ export const addToDeleteSet = (ds, id, length) => {
map.setIfUndefined(ds.clients, id.client, () => []).push(new DeleteItem(id.clock, length))
}
export const createDeleteSet = () => new DeleteSet()
/**
* @param {StructStore} ss
* @return {DeleteSet} Merged and sorted DeleteSet
@ -177,7 +184,7 @@ export const addToDeleteSet = (ds, id, length) => {
* @function
*/
export const createDeleteSetFromStructStore = ss => {
const ds = new DeleteSet()
const ds = createDeleteSet()
ss.clients.forEach((structs, client) => {
/**
* @type {Array<DeleteItem>}
@ -224,6 +231,26 @@ export const writeDeleteSet = (encoder, ds) => {
})
}
/**
* @param {decoding.Decoder} decoder
* @return {DeleteSet}
*
* @private
* @function
*/
export const readDeleteSet = decoder => {
const ds = new DeleteSet()
const numClients = decoding.readVarUint(decoder)
for (let i = 0; i < numClients; i++) {
const client = decoding.readVarUint(decoder)
const numberOfDeletes = decoding.readVarUint(decoder)
for (let i = 0; i < numberOfDeletes; i++) {
addToDeleteSet(ds, createID(client, decoding.readVarUint(decoder)), decoding.readVarUint(decoder))
}
}
return ds
}
/**
* @param {decoding.Decoder} decoder
* @param {Transaction} transaction
@ -232,7 +259,7 @@ export const writeDeleteSet = (encoder, ds) => {
* @private
* @function
*/
export const readDeleteSet = (decoder, transaction, store) => {
export const readAndApplyDeleteSet = (decoder, transaction, store) => {
const unappliedDS = new DeleteSet()
const numClients = decoding.readVarUint(decoder)
for (let i = 0; i < numClients; i++) {
@ -279,6 +306,7 @@ export const readDeleteSet = (decoder, transaction, store) => {
}
}
if (unappliedDS.clients.size > 0) {
// TODO: no need for encoding+decoding ds anymore
const unappliedDSEncoder = encoding.createEncoder()
writeDeleteSet(unappliedDSEncoder, unappliedDS)
store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toUint8Array(unappliedDSEncoder)))

View File

@ -0,0 +1,134 @@
import {
YArray,
YMap,
readDeleteSet,
writeDeleteSet,
createDeleteSet,
ID, DeleteSet, YArrayEvent, Transaction, Doc // eslint-disable-line
} from '../internals.js'
import * as decoding from 'lib0/decoding.js'
import * as encoding from 'lib0/encoding.js'
import { mergeDeleteSets, isDeleted } from './DeleteSet.js'
export class PermanentUserData {
/**
* @param {Doc} doc
* @param {string} key
*/
constructor (doc, key = 'users') {
const users = doc.getMap(key)
/**
* @type {Map<string,DeleteSet>}
*/
const dss = new Map()
this.yusers = users
this.doc = doc
/**
* Maps from clientid to userDescription
*
* @type {Map<number,string>}
*/
this.clients = new Map()
this.dss = dss
/**
* @param {YMap<any>} user
* @param {string} userDescription
*/
const initUser = (user, userDescription) => {
/**
* @type {YArray<Uint8Array>}
*/
const ds = user.get('ds')
const ids = user.get('ids')
const addClientId = /** @param {number} clientid */ clientid => this.clients.set(clientid, userDescription)
ds.observe(/** @param {YArrayEvent<any>} event */ event => {
event.changes.added.forEach(item => {
item.content.getContent().forEach(encodedDs => {
if (encodedDs instanceof Uint8Array) {
this.dss.set(userDescription, mergeDeleteSets([this.dss.get(userDescription) || createDeleteSet(), readDeleteSet(decoding.createDecoder(encodedDs))]))
}
})
})
})
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(decoding.createDecoder(encodedDs)))))
ids.observe(/** @param {YArrayEvent<any>} event */ event =>
event.changes.added.forEach(item => item.content.getContent().forEach(addClientId))
)
ids.forEach(addClientId)
}
// observe users
users.observe(event => {
event.keysChanged.forEach(userDescription =>
initUser(users.get(userDescription), userDescription)
)
})
// add intial data
users.forEach(initUser)
}
/**
* @param {Doc} doc
* @param {number} clientid
* @param {string} userDescription
*/
setUserMapping (doc, clientid, userDescription) {
const users = this.yusers
let user = users.get(userDescription)
if (!user) {
user = new YMap()
user.set('ids', new YArray())
user.set('ds', new YArray())
users.set(userDescription, user)
}
user.get('ids').push([clientid])
users.observe(event => {
const userOverwrite = users.get(userDescription)
if (userOverwrite !== user) {
// user was overwritten, port all data over to the next user object
// @todo Experiment with Y.Sets here
user = userOverwrite
// @todo iterate over old type
this.clients.forEach((_userDescription, clientid) => {
if (userDescription === _userDescription) {
user.get('ids').push([clientid])
}
})
const encoder = encoding.createEncoder()
const ds = this.dss.get(userDescription)
if (ds) {
writeDeleteSet(encoder, ds)
user.get('ds').push([encoding.toUint8Array(encoder)])
}
}
})
doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
const yds = user.get('ds')
const ds = transaction.deleteSet
if (transaction.local && ds.clients.size > 0) {
const encoder = encoding.createEncoder()
writeDeleteSet(encoder, ds)
yds.push([encoding.toUint8Array(encoder)])
}
})
}
/**
* @param {number} clientid
* @return {any}
*/
getUserByClientId (clientid) {
return this.clients.get(clientid) || null
}
/**
* @param {ID} id
* @return {string | null}
*/
getUserByDeletedId (id) {
for (const [userDescription, ds] of this.dss) {
if (isDeleted(ds, id)) {
return userDescription
}
}
return null
}
}

View File

@ -228,7 +228,7 @@ export const createAbsolutePositionFromRelativePosition = (rpos, doc) => {
return null
}
type = right.parent
if (type._item !== null && !type._item.deleted) {
if (type._item === null || !type._item.deleted) {
index = right.deleted || !right.countable ? 0 : res.diff
let n = right.left
while (n !== null) {

View File

@ -1,15 +1,31 @@
import {
isDeleted,
DeleteSet, Item // eslint-disable-line
createDeleteSetFromStructStore,
getStateVector,
getItemCleanStart,
createID,
iterateDeletedStructs,
writeDeleteSet,
writeStateVector,
readDeleteSet,
readStateVector,
createDeleteSet,
getState,
Transaction, Doc, DeleteSet, Item // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as encoding from 'lib0/encoding.js'
import * as decoding from 'lib0/decoding.js'
export class Snapshot {
/**
* @param {DeleteSet} ds
* @param {Map<number,number>} sm state map
* @param {Map<number,number>} sv state map
*/
constructor (ds, sm) {
constructor (ds, sv) {
/**
* @type {DeleteSet}
* @private
@ -20,16 +36,79 @@ export class Snapshot {
* @type {Map<number,number>}
* @private
*/
this.sm = sm
this.sv = sv
}
}
/**
* @param {Snapshot} snap1
* @param {Snapshot} snap2
* @return {boolean}
*/
export const equalSnapshots = (snap1, snap2) => {
const ds1 = snap1.ds.clients
const ds2 = snap2.ds.clients
const sv1 = snap1.sv
const sv2 = snap2.sv
if (sv1.size !== sv2.size || ds1.size !== ds2.size) {
return false
}
for (const [key, value] of sv1) {
if (sv2.get(key) !== value) {
return false
}
}
for (const [client, dsitems1] of ds1) {
const dsitems2 = ds2.get(client) || []
if (dsitems1.length !== dsitems2.length) {
return false
}
for (let i = 0; i < dsitems1.length; i++) {
const dsitem1 = dsitems1[i]
const dsitem2 = dsitems2[i]
if (dsitem1.clock !== dsitem2.clock || dsitem1.len !== dsitem2.len) {
return false
}
}
}
return true
}
/**
* @param {Snapshot} snapshot
* @return {Uint8Array}
*/
export const encodeSnapshot = snapshot => {
const encoder = encoding.createEncoder()
writeDeleteSet(encoder, snapshot.ds)
writeStateVector(encoder, snapshot.sv)
return encoding.toUint8Array(encoder)
}
/**
* @param {Uint8Array} buf
* @return {Snapshot}
*/
export const decodeSnapshot = buf => {
const decoder = decoding.createDecoder(buf)
return new Snapshot(readDeleteSet(decoder), readStateVector(decoder))
}
/**
* @param {DeleteSet} ds
* @param {Map<number,number>} sm
* @return {Snapshot}
*/
export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
export const emptySnapshot = createSnapshot(createDeleteSet(), new Map())
/**
* @param {Doc} doc
* @return {Snapshot}
*/
export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store))
/**
* @param {Item} item
* @param {Snapshot|undefined} snapshot
@ -38,5 +117,24 @@ export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
* @function
*/
export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : (
snapshot.sm.has(item.id.client) && (snapshot.sm.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
)
/**
* @param {Transaction} transaction
* @param {Snapshot} snapshot
*/
export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
const meta = map.setIfUndefined(transaction.meta, splitSnapshotAffectedStructs, set.create)
const store = transaction.doc.store
// check if we already split for this snapshot
if (!meta.has(snapshot)) {
snapshot.sv.forEach((clock, client) => {
if (clock < getState(store, client)) {
getItemCleanStart(transaction, createID(client, clock))
}
})
iterateDeletedStructs(transaction, snapshot.ds, item => {})
meta.add(snapshot)
}
}

View File

@ -197,16 +197,15 @@ export const findIndexCleanStart = (transaction, structs, clock) => {
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
*
* @param {Transaction} transaction
* @param {StructStore} store
* @param {ID} id
* @return {Item}
*
* @private
* @function
*/
export const getItemCleanStart = (transaction, store, id) => {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(id.client))
return /** @type {Item} */ (structs[findIndexCleanStart(transaction, structs, id.clock)])
export const getItemCleanStart = (transaction, id) => {
const structs = /** @type {Array<Item>} */ (transaction.doc.store.clients.get(id.client))
return structs[findIndexCleanStart(transaction, structs, id.clock)]
}
/**

View File

@ -17,6 +17,7 @@ import * as encoding from 'lib0/encoding.js'
import * as map from 'lib0/map.js'
import * as math from 'lib0/math.js'
import * as set from 'lib0/set.js'
import { callAll } from 'lib0/function.js'
/**
* A transaction is created for every change on the Yjs model. It is possible
@ -46,8 +47,9 @@ export class Transaction {
/**
* @param {Doc} doc
* @param {any} origin
* @param {boolean} local
*/
constructor (doc, origin) {
constructor (doc, origin, local) {
/**
* The Yjs instance.
* @type {Doc}
@ -90,6 +92,16 @@ export class Transaction {
* @type {any}
*/
this.origin = origin
/**
* Stores meta information on the transaction
* @type {Map<any,any>}
*/
this.meta = new Map()
/**
* Whether this change originates from this doc.
* @type {boolean}
*/
this.local = local
}
}
@ -133,22 +145,180 @@ export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
}
}
/**
* @param {Array<Transaction>} transactionCleanups
* @param {number} i
*/
const cleanupTransactions = (transactionCleanups, i) => {
if (i < transactionCleanups.length) {
const transaction = transactionCleanups[i]
const doc = transaction.doc
const store = doc.store
const ds = transaction.deleteSet
try {
sortAndMergeDeleteSet(ds)
transaction.afterState = getStateVector(transaction.doc.store)
doc._transaction = null
doc.emit('beforeObserverCalls', [transaction, doc])
/**
* An array of event callbacks.
*
* Each callback is called even if the other ones throw errors.
*
* @type {Array<function():void>}
*/
const fs = []
// observe events on changed types
transaction.changed.forEach((subs, itemtype) =>
fs.push(() => {
if (itemtype._item === null || !itemtype._item.deleted) {
itemtype._callObserver(transaction, subs)
}
})
)
fs.push(() => {
// deep observe events
transaction.changedParentTypes.forEach((events, type) =>
fs.push(() => {
// We need to think about the possibility that the user transforms the
// Y.Doc in the event.
if (type._item === null || !type._item.deleted) {
events = events
.filter(event =>
event.target._item === null || !event.target._item.deleted
)
events
.forEach(event => {
event.currentTarget = type
})
// We don't need to check for events.length
// because we know it has at least one element
callEventHandlerListeners(type._dEH, events, transaction)
}
})
)
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
})
callAll(fs, [])
} finally {
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
*/
const tryToMergeWithLeft = (structs, pos) => {
const left = structs[pos - 1]
const right = structs[pos]
if (left.deleted === right.deleted && left.constructor === right.constructor) {
if (left.mergeWith(right)) {
structs.splice(pos, 1)
if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
}
}
}
}
// Replace deleted items with ItemDeleted / GC.
// This is where content is actually remove from the Yjs Doc.
if (doc.gc) {
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
const endDeleteItemClock = deleteItem.clock + deleteItem.len
for (
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
si < structs.length && struct.id.clock < endDeleteItemClock;
struct = structs[++si]
) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct instanceof Item && struct.deleted && !struct.keep) {
struct.gc(store, false)
}
}
}
}
}
// try to merge deleted / gc'd items
// merge from right to left for better efficiecy and so we don't miss any merge targets
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
// start with merging the item next to the last deleted item
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
for (
let si = mostRightIndexToCheck, struct = structs[si];
si > 0 && struct.id.clock >= deleteItem.clock;
struct = structs[--si]
) {
tryToMergeWithLeft(structs, si)
}
}
}
// on all affected store.clients props, try to merge
for (const [client, clock] of transaction.afterState) {
const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
// we iterate from right to left so we can safely remove entries
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
for (let i = structs.length - 1; i >= firstChangePos; i--) {
tryToMergeWithLeft(structs, i)
}
}
}
// try to merge mergeStructs
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
// but at the moment DS does not handle duplicates
for (const mid of transaction._mergeStructs) {
const client = mid.client
const clock = mid.clock
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
const replacedStructPos = findIndexSS(structs, clock)
if (replacedStructPos + 1 < structs.length) {
tryToMergeWithLeft(structs, replacedStructPos + 1)
}
if (replacedStructPos > 0) {
tryToMergeWithLeft(structs, replacedStructPos)
}
}
// @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc])
if (doc._observers.has('update')) {
const updateMessage = computeUpdateMessageFromTransaction(transaction)
if (updateMessage !== null) {
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
}
}
if (transactionCleanups.length <= i + 1) {
doc._transactionCleanups = []
} else {
cleanupTransactions(transactionCleanups, i + 1)
}
}
}
}
/**
* Implements the functionality of `y.transact(()=>{..})`
*
* @param {Doc} doc
* @param {function(Transaction):void} f
* @param {any} [origin]
* @param {any} [origin=true]
*
* @private
* @function
*/
export const transact = (doc, f, origin = null) => {
export const transact = (doc, f, origin = null, local = true) => {
const transactionCleanups = doc._transactionCleanups
let initialCall = false
if (doc._transaction === null) {
initialCall = true
doc._transaction = new Transaction(doc, origin)
doc._transaction = new Transaction(doc, origin, local)
transactionCleanups.push(doc._transaction)
doc.emit('beforeTransaction', [doc._transaction, doc])
}
@ -158,134 +328,13 @@ export const transact = (doc, f, origin = null) => {
if (initialCall && transactionCleanups[0] === doc._transaction) {
// The first transaction ended, now process observer calls.
// Observer call may create new transactions for which we need to call the observers and do cleanup.
// We don't want to nest these calls, so we execute these calls one after another
for (let i = 0; i < transactionCleanups.length; i++) {
const transaction = transactionCleanups[i]
const store = transaction.doc.store
const ds = transaction.deleteSet
sortAndMergeDeleteSet(ds)
transaction.afterState = getStateVector(transaction.doc.store)
doc._transaction = null
doc.emit('beforeObserverCalls', [transaction, doc])
// emit change events on changed types
transaction.changed.forEach((subs, itemtype) => {
if (itemtype._item === null || !itemtype._item.deleted) {
itemtype._callObserver(transaction, subs)
}
})
transaction.changedParentTypes.forEach((events, type) => {
// We need to think about the possibility that the user transforms the
// Y.Doc in the event.
if (type._item === null || !type._item.deleted) {
events = events
.filter(event =>
event.target._item === null || !event.target._item.deleted
)
events
.forEach(event => {
event.currentTarget = type
})
// We don't need to check for events.length
// because we know it has at least one element
callEventHandlerListeners(type._dEH, events, transaction)
}
})
doc.emit('afterTransaction', [transaction, doc])
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
*/
const tryToMergeWithLeft = (structs, pos) => {
const left = structs[pos - 1]
const right = structs[pos]
if (left.deleted === right.deleted && left.constructor === right.constructor) {
if (left.mergeWith(right)) {
structs.splice(pos, 1)
if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
}
}
}
}
// Replace deleted items with ItemDeleted / GC.
// This is where content is actually remove from the Yjs Doc.
if (doc.gc) {
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
const endDeleteItemClock = deleteItem.clock + deleteItem.len
for (
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
si < structs.length && struct.id.clock < endDeleteItemClock;
struct = structs[++si]
) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct instanceof Item && struct.deleted && !struct.keep) {
struct.gc(store, false)
}
}
}
}
}
// try to merge deleted / gc'd items
// merge from right to left for better efficiecy and so we don't miss any merge targets
for (const [client, deleteItems] of ds.clients) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
// start with merging the item next to the last deleted item
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
for (
let si = mostRightIndexToCheck, struct = structs[si];
si > 0 && struct.id.clock >= deleteItem.clock;
struct = structs[--si]
) {
tryToMergeWithLeft(structs, si)
}
}
}
// on all affected store.clients props, try to merge
for (const [client, clock] of transaction.afterState) {
const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) {
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
// we iterate from right to left so we can safely remove entries
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
for (let i = structs.length - 1; i >= firstChangePos; i--) {
tryToMergeWithLeft(structs, i)
}
}
}
// try to merge mergeStructs
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
// but at the moment DS does not handle duplicates
for (const mid of transaction._mergeStructs) {
const client = mid.client
const clock = mid.clock
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
const replacedStructPos = findIndexSS(structs, clock)
if (replacedStructPos + 1 < structs.length) {
tryToMergeWithLeft(structs, replacedStructPos + 1)
}
if (replacedStructPos > 0) {
tryToMergeWithLeft(structs, replacedStructPos)
}
}
// @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc])
if (doc._observers.has('update')) {
const updateMessage = computeUpdateMessageFromTransaction(transaction)
if (updateMessage !== null) {
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
}
}
}
doc._transactionCleanups = []
// We don't want to nest these calls, so we execute these calls one after
// another.
// Also we need to ensure that all cleanups are called, even if the
// observes throw errors.
// This file is full of hacky try {} finally {} blocks to ensure that an
// event can throw errors and also that the cleanup is called.
cleanupTransactions(transactionCleanups, 0)
}
}
}

View File

@ -9,6 +9,7 @@ import {
createID,
followRedone,
getItemCleanStart,
getState,
Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
} from '../internals.js'
@ -49,35 +50,64 @@ const popStackItem = (undoManager, stack, eventType) => {
transact(doc, transaction => {
while (stack.length > 0 && result === null) {
const store = doc.store
const clientID = doc.clientID
const stackItem = /** @type {StackItem} */ (stack.pop())
const stackStartClock = stackItem.start
const stackEndClock = stackItem.start + stackItem.len
const itemsToRedo = new Set()
// @todo iterateStructs should not need the structs parameter
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(clientID))
let performedChange = false
iterateDeletedStructs(transaction, stackItem.ds, store, struct => {
if (struct instanceof Item && scope.some(type => isParentOf(type, struct))) {
if (stackStartClock !== stackEndClock) {
// make sure structs don't overlap with the range of created operations [stackItem.start, stackItem.start + stackItem.end)
getItemCleanStart(transaction, createID(clientID, stackStartClock))
if (stackEndClock < getState(doc.store, clientID)) {
getItemCleanStart(transaction, createID(clientID, stackEndClock))
}
}
iterateDeletedStructs(transaction, stackItem.ds, struct => {
if (
struct instanceof Item &&
scope.some(type => isParentOf(type, struct)) &&
// Never redo structs in [stackItem.start, stackItem.start + stackItem.end) because they were created and deleted in the same capture interval.
!(struct.id.client === clientID && struct.id.clock >= stackStartClock && struct.id.clock < stackEndClock)
) {
itemsToRedo.add(struct)
}
})
itemsToRedo.forEach(item => {
performedChange = redoItem(transaction, item, itemsToRedo) !== null || performedChange
itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
})
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(doc.clientID))
iterateStructs(transaction, structs, stackItem.start, stackItem.len, struct => {
if (struct instanceof Item && !struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
/**
* @type {Array<Item>}
*/
const itemsToDelete = []
iterateStructs(transaction, structs, stackStartClock, stackItem.len, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, store, createID(item.id.client, item.id.clock + diff))
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
}
if (item.length > stackItem.len) {
getItemCleanStart(transaction, store, createID(item.id.client, item.id.clock + stackItem.len))
getItemCleanStart(transaction, createID(item.id.client, stackEndClock))
}
struct = item
}
keepItem(struct)
struct.delete(transaction)
performedChange = true
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
})
// We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered.
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
const item = itemsToDelete[i]
if (undoManager.deleteFilter(item)) {
item.delete(transaction)
performedChange = true
}
}
result = stackItem
if (result != null) {
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
@ -87,26 +117,39 @@ const popStackItem = (undoManager, stack, eventType) => {
return result
}
/**
* @typedef {Object} UndoManagerOptions
* @property {number} [UndoManagerOptions.captureTimeout=500]
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
* it is necessary to filter whan an Undo/Redo operation can delete. If this
* filter returns false, the type/item won't be deleted even it is in the
* undo/redo scope.
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
*/
/**
* Fires 'stack-item-added' event when a stack item was added to either the undo- or
* the redo-stack. You may store additional stack information via the
* metadata property on `event.stackItem.metadata` (it is a `Map` of metadata properties).
* metadata property on `event.stackItem.meta` (it is a `Map` of metadata properties).
* Fires 'stack-item-popped' event when a stack item was popped from either the
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.metadata`.
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
*
* @extends {Observable<'stack-item-added'|'stack-item-popped'>}
*/
export class UndoManager extends Observable {
/**
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
* @param {Set<any>} [trackedTransactionOrigins=new Set([null])]
* @param {object} [options={captureTimeout=500}]
* @param {UndoManagerOptions} options
*/
constructor (typeScope, trackedTransactionOrigins = new Set([null]), { captureTimeout = 500 } = {}) {
constructor (typeScope, { captureTimeout, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
if (captureTimeout == null) {
captureTimeout = 500
}
super()
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
trackedTransactionOrigins.add(this)
this.trackedTransactionOrigins = trackedTransactionOrigins
this.deleteFilter = deleteFilter
trackedOrigins.add(this)
this.trackedOrigins = trackedOrigins
/**
* @type {Array<StackItem>}
*/
@ -126,7 +169,7 @@ export class UndoManager extends Observable {
this.lastChange = 0
this.doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
// Only track certain transactions
if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedTransactionOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedTransactionOrigins.has(transaction.origin.constructor)))) {
if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))) {
return
}
const undoing = this.undoing
@ -144,7 +187,7 @@ export class UndoManager extends Observable {
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op
const lastOp = stack[stack.length - 1]
lastOp.ds = mergeDeleteSets(lastOp.ds, transaction.deleteSet)
lastOp.ds = mergeDeleteSets([lastOp.ds, transaction.deleteSet])
lastOp.len = afterState - lastOp.start
} else {
// create a new stack op
@ -154,7 +197,7 @@ export class UndoManager extends Observable {
this.lastChange = now
}
// make sure that deleted structs are not gc'd
iterateDeletedStructs(transaction, transaction.deleteSet, transaction.doc.store, /** @param {Item|GC} item */ item => {
iterateDeletedStructs(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) {
keepItem(item)
}

View File

@ -1,9 +1,12 @@
import {
isDeleted,
AbstractType, Transaction, AbstractStruct // eslint-disable-line
Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
} from '../internals.js'
import * as set from 'lib0/set.js'
import * as array from 'lib0/array.js'
/**
* YEvent describes the changes on a YType.
*/
@ -28,6 +31,10 @@ export class YEvent {
* @type {Transaction}
*/
this.transaction = transaction
/**
* @type {Object|null}
*/
this._changes = null
}
/**
@ -49,6 +56,8 @@ export class YEvent {
/**
* Check if a struct is deleted by this event.
*
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
*
* @param {AbstractStruct} struct
* @return {boolean}
*/
@ -59,12 +68,121 @@ export class YEvent {
/**
* Check if a struct is added by this event.
*
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
*
* @param {AbstractStruct} struct
* @return {boolean}
*/
adds (struct) {
return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0)
}
/**
* @return {{added:Set<Item>,deleted:Set<Item>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
*/
get changes () {
let changes = this._changes
if (changes === null) {
const target = this.target
const added = set.create()
const deleted = set.create()
/**
* @type {Array<{insert:Array<any>}|{delete:number}|{retain:number}>}
*/
const delta = []
/**
* @type {Map<string,{ action: 'add' | 'update' | 'delete', oldValue: any}>}
*/
const keys = new Map()
changes = {
added, deleted, delta, keys
}
const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
if (changed.has(null)) {
/**
* @type {any}
*/
let lastOp = null
const packOp = () => {
if (lastOp) {
delta.push(lastOp)
}
}
for (let item = target._start; item !== null; item = item.right) {
if (item.deleted) {
if (this.deletes(item) && !this.adds(item)) {
if (lastOp === null || lastOp.delete === undefined) {
packOp()
lastOp = { delete: 0 }
}
lastOp.delete += item.length
deleted.add(item)
} // else nop
} else {
if (this.adds(item)) {
if (lastOp === null || lastOp.insert === undefined) {
packOp()
lastOp = { insert: [] }
}
lastOp.insert = lastOp.insert.concat(item.content.getContent())
added.add(item)
} else {
if (lastOp === null || lastOp.retain === undefined) {
packOp()
lastOp = { retain: 0 }
}
lastOp.retain += item.length
}
}
}
if (lastOp !== null && lastOp.retain === undefined) {
packOp()
}
}
changed.forEach(key => {
if (key !== null) {
const item = /** @type {Item} */ (target._map.get(key))
/**
* @type {'delete' | 'add' | 'update'}
*/
let action
let oldValue
if (this.adds(item)) {
let prev = item.left
while (prev !== null && this.adds(prev)) {
prev = prev.left
}
if (this.deletes(item)) {
if (prev !== null && this.deletes(prev)) {
action = 'delete'
oldValue = array.last(prev.content.getContent())
} else {
return
}
} else {
if (prev !== null && this.deletes(prev)) {
action = 'update'
oldValue = array.last(prev.content.getContent())
} else {
action = 'add'
oldValue = undefined
}
}
} else {
if (this.deletes(item)) {
action = 'delete'
oldValue = array.last(/** @type {Item} */ item.content.getContent())
} else {
return // nop
}
}
keys.set(key, { action, oldValue })
}
})
this._changes = changes
}
return changes
}
}
/**

View File

@ -23,9 +23,10 @@ import {
readID,
getState,
getStateVector,
readDeleteSet,
readAndApplyDeleteSet,
writeDeleteSet,
createDeleteSetFromStructStore,
transact,
Doc, Transaction, AbstractStruct, StructStore, ID // eslint-disable-line
} from '../internals.js'
@ -230,7 +231,7 @@ export const tryResumePendingDeleteReaders = (transaction, store) => {
const pendingReaders = store.pendingDeleteReaders
store.pendingDeleteReaders = []
for (let i = 0; i < pendingReaders.length; i++) {
readDeleteSet(pendingReaders[i], transaction, store)
readAndApplyDeleteSet(pendingReaders[i], transaction, store)
}
}
@ -299,10 +300,10 @@ export const readStructs = (decoder, transaction, store) => {
* @function
*/
export const readUpdate = (decoder, ydoc, transactionOrigin) =>
ydoc.transact(transaction => {
transact(ydoc, transaction => {
readStructs(decoder, transaction, ydoc.store)
readDeleteSet(decoder, transaction, ydoc.store)
}, transactionOrigin)
readAndApplyDeleteSet(decoder, transaction, ydoc.store)
}, transactionOrigin, false)
/**
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
@ -381,6 +382,22 @@ export const readStateVector = decoder => {
*/
export const decodeStateVector = decodedState => readStateVector(decoding.createDecoder(decodedState))
/**
* Write State Vector to `lib0/encoding.js#Encoder`.
*
* @param {encoding.Encoder} encoder
* @param {Map<number,number>} sv
* @function
*/
export const writeStateVector = (encoder, sv) => {
encoding.writeVarUint(encoder, sv.size)
sv.forEach((clock, client) => {
encoding.writeVarUint(encoder, client)
encoding.writeVarUint(encoder, clock)
})
return encoder
}
/**
* Write State Vector to `lib0/encoding.js#Encoder`.
*
@ -389,16 +406,7 @@ export const decodeStateVector = decodedState => readStateVector(decoding.create
*
* @function
*/
export const writeDocumentStateVector = (encoder, doc) => {
encoding.writeVarUint(encoder, doc.store.clients.size)
doc.store.clients.forEach((structs, client) => {
const struct = structs[structs.length - 1]
const id = struct.id
encoding.writeVarUint(encoder, id.client)
encoding.writeVarUint(encoder, id.clock + struct.length)
})
return encoder
}
export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store))
/**
* Encode State as Uint8Array.

View File

@ -8,19 +8,21 @@ import {
readContentJSON,
readContentEmbed,
readContentType,
readContentFormat
readContentFormat,
readContentAny
} from '../src/internals.js'
/**
* @param {t.TestCase} tc
*/
export const testStructReferences = tc => {
t.assert(contentRefs.length === 8)
t.assert(contentRefs.length === 9)
t.assert(contentRefs[1] === readContentDeleted)
t.assert(contentRefs[2] === readContentJSON)
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
t.assert(contentRefs[3] === readContentBinary)
t.assert(contentRefs[4] === readContentString)
t.assert(contentRefs[5] === readContentEmbed)
t.assert(contentRefs[6] === readContentFormat)
t.assert(contentRefs[7] === readContentType)
t.assert(contentRefs[8] === readContentAny)
}

View File

@ -378,7 +378,7 @@ export const compareDS = (ds1, ds2) => {
*/
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
const gen = tc.prng
const result = init(tc, { users: 5 }, initTestObject || (() => null))
const result = init(tc, { users: 5 }, initTestObject)
const { testConnector, users } = result
for (let i = 0; i < iterations; i++) {
if (prng.int31(gen, 0, 100) <= 2) {

View File

@ -13,6 +13,23 @@ import * as t from 'lib0/testing.js'
export const testUndoText = tc => {
const { testConnector, text0, text1 } = init(tc, { users: 3 })
const undoManager = new UndoManager(text0)
// items that are added & deleted in the same transaction won't be undo
text0.insert(0, 'test')
text0.delete(0, 4)
undoManager.undo()
t.assert(text0.toString() === '')
// follow redone items
text0.insert(0, 'a')
undoManager.stopCapturing()
text0.delete(0, 1)
undoManager.stopCapturing()
undoManager.undo()
t.assert(text0.toString() === 'a')
undoManager.undo()
t.assert(text0.toString() === '')
text0.insert(0, 'abc')
text1.insert(0, 'xyz')
testConnector.syncAll()
@ -65,6 +82,15 @@ export const testUndoMap = tc => {
t.assert(map0.get('a') === 44)
undoManager.redo()
t.assert(map0.get('a') === 44)
// test setting value multiple times
map0.set('b', 'initial')
undoManager.stopCapturing()
map0.set('b', 'val1')
map0.set('b', 'val2')
undoManager.stopCapturing()
undoManager.undo()
t.assert(map0.get('b') === 'initial')
}
/**
@ -172,7 +198,7 @@ export const testUndoEvents = tc => {
export const testTrackClass = tc => {
const { users, text0 } = init(tc, { users: 3 })
// only track origins that are numbers
const undoManager = new UndoManager(text0, new Set([Number]))
const undoManager = new UndoManager(text0, { trackedOrigins: new Set([Number]) })
users[0].transact(() => {
text0.insert(0, 'abc')
}, 42)
@ -201,3 +227,22 @@ export const testTypeScope = tc => {
undoManagerBoth.undo()
t.assert(text1.toString() === '')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoDeleteFilter = tc => {
/**
* @type {Array<Y.Map<any>>}
*/
const array0 = /** @type {any} */ (init(tc, { users: 3 }).array0)
const undoManager = new UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
const map0 = new Y.Map()
map0.set('hi', 1)
const map1 = new Y.Map()
array0.insert(0, [map0, map1])
undoManager.undo()
t.assert(array0.length === 1)
array0.get(0)
t.assert(Array.from(array0.get(0).keys()).length === 1)
}

View File

@ -3,6 +3,7 @@ import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint
import * as Y from '../src/index.js'
import * as t from 'lib0/testing.js'
import * as prng from 'lib0/prng.js'
import * as math from 'lib0/math.js'
/**
* @param {t.TestCase} tc
@ -191,6 +192,33 @@ export const testInsertAndDeleteEventsForTypes = tc => {
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testChangeEvent = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {any}
*/
let changes = null
array0.observe(e => {
changes = e.changes
})
const newArr = new Y.Array()
array0.insert(0, [newArr, 4, 'dtrn'])
t.assert(changes !== null && changes.added.size === 2 && changes.deleted.size === 0)
t.compare(changes.delta, [{ insert: [newArr, 4, 'dtrn'] }])
changes = null
array0.delete(0, 2)
t.assert(changes !== null && changes.added.size === 0 && changes.deleted.size === 2)
t.compare(changes.delta, [{ delete: 2 }])
changes = null
array0.insert(1, [0.1])
t.assert(changes !== null && changes.added.size === 1 && changes.deleted.size === 0)
t.compare(changes.delta, [{ retain: 1 }, { insert: [0.1] }])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
@ -211,7 +239,7 @@ export const testInsertAndDeleteEventsForTypes2 = tc => {
}
/**
* This issue has been reported here https://github.com/y-js/yjs/issues/155
* This issue has been reported here https://github.com/yjs/yjs/issues/155
* @param {t.TestCase} tc
*/
export const testNewChildDoesNotEmitEventInTransaction = tc => {
@ -335,12 +363,12 @@ const arrayTransactions = [
var length = yarray.length
if (length > 0) {
var somePos = prng.int31(gen, 0, length - 1)
var delLength = prng.int31(gen, 1, Math.min(2, length - somePos))
var delLength = prng.int31(gen, 1, math.min(2, length - somePos))
if (prng.bool(gen)) {
var type = yarray.get(somePos)
if (type.length > 0) {
somePos = prng.int31(gen, 0, type.length - 1)
delLength = prng.int31(gen, 0, Math.min(2, type.length - somePos))
delLength = prng.int31(gen, 0, math.min(2, type.length - somePos))
type.delete(somePos, delLength)
}
} else {

View File

@ -292,6 +292,104 @@ export const testThrowsAddAndUpdateAndDeleteEvents = tc => {
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testChangeEvent = tc => {
const { map0, users } = init(tc, { users: 2 })
/**
* @type {any}
*/
let changes = null
/**
* @type {any}
*/
let keyChange = null
map0.observe(e => {
changes = e.changes
})
map0.set('a', 1)
keyChange = changes.keys.get('a')
t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined)
map0.set('a', 2)
keyChange = changes.keys.get('a')
t.assert(changes !== null && keyChange.action === 'update' && keyChange.oldValue === 1)
users[0].transact(() => {
map0.set('a', 3)
map0.set('a', 4)
})
keyChange = changes.keys.get('a')
t.assert(changes !== null && keyChange.action === 'update' && keyChange.oldValue === 2)
users[0].transact(() => {
map0.set('b', 1)
map0.set('b', 2)
})
keyChange = changes.keys.get('b')
t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined)
users[0].transact(() => {
map0.set('c', 1)
map0.delete('c')
})
t.assert(changes !== null && changes.keys.size === 0)
users[0].transact(() => {
map0.set('d', 1)
map0.set('d', 2)
})
keyChange = changes.keys.get('d')
t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined)
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapEventExceptionsShouldCompleteTransaction = tc => {
const doc = new Y.Doc()
const map = doc.getMap('map')
let updateCalled = false
let throwingObserverCalled = false
let throwingDeepObserverCalled = false
doc.on('update', () => {
updateCalled = true
})
const throwingObserver = () => {
throwingObserverCalled = true
throw new Error('Failure')
}
const throwingDeepObserver = () => {
throwingDeepObserverCalled = true
throw new Error('Failure')
}
map.observe(throwingObserver)
map.observeDeep(throwingDeepObserver)
t.fails(() => {
map.set('y', '2')
})
t.assert(updateCalled)
t.assert(throwingObserverCalled)
t.assert(throwingDeepObserverCalled)
// check if it works again
updateCalled = false
throwingObserverCalled = false
throwingDeepObserverCalled = false
t.fails(() => {
map.set('z', '3')
})
t.assert(updateCalled)
t.assert(throwingObserverCalled)
t.assert(throwingDeepObserverCalled)
t.assert(map.get('z') === '3')
}
/**
* @param {t.TestCase} tc
*/

View File

@ -1,6 +1,6 @@
import { init, compare } from './testHelper.js'
import * as Y from './testHelper.js'
import * as t from 'lib0/testing.js'
const { init, compare } = Y
/**
* @param {t.TestCase} tc
@ -81,9 +81,71 @@ export const testBasicFormat = tc => {
export const testGetDeltaWithEmbeds = tc => {
const { text0 } = init(tc, { users: 1 })
text0.applyDelta([{
insert: {linebreak: 's'}
insert: { linebreak: 's' }
}])
t.compare(text0.toDelta(), [{
insert: {linebreak: 's'}
insert: { linebreak: 's' }
}])
}
/**
* @param {t.TestCase} tc
*/
export const testSnapshot = tc => {
const { text0 } = init(tc, { users: 1 })
const doc0 = /** @type {Y.Doc} */ (text0.doc)
doc0.gc = false
text0.applyDelta([{
insert: 'abcd'
}])
const snapshot1 = Y.snapshot(doc0)
text0.applyDelta([{
retain: 1
}, {
insert: 'x'
}, {
delete: 1
}])
const snapshot2 = Y.snapshot(doc0)
text0.applyDelta([{
retain: 2
}, {
delete: 3
}, {
insert: 'x'
}, {
delete: 1
}])
const state1 = text0.toDelta(snapshot1)
t.compare(state1, [{ insert: 'abcd' }])
const state2 = text0.toDelta(snapshot2)
t.compare(state2, [{ insert: 'axcd' }])
const state2Diff = text0.toDelta(snapshot2, snapshot1)
// @ts-ignore Remove userid info
state2Diff.forEach(v => {
if (v.attributes && v.attributes.ychange) {
delete v.attributes.ychange.user
}
})
t.compare(state2Diff, [{ insert: 'a' }, { insert: 'x', attributes: { ychange: { type: 'added' } } }, { insert: 'b', attributes: { ychange: { type: 'removed' } } }, { insert: 'cd' }])
}
/**
* @param {t.TestCase} tc
*/
export const testSnapshotDeleteAfter = tc => {
const { text0 } = init(tc, { users: 1 })
const doc0 = /** @type {Y.Doc} */ (text0.doc)
doc0.gc = false
text0.applyDelta([{
insert: 'abcd'
}])
const snapshot1 = Y.snapshot(doc0)
text0.applyDelta([{
retain: 4
}, {
insert: 'e'
}])
const state1 = text0.toDelta(snapshot1)
t.compare(state1, [{ insert: 'abcd' }])
}

View File

@ -38,7 +38,10 @@
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"paths": {
"yjs": ["./src/index.js"]
"yjs": ["./src/index.js"],
"lib0/*": ["node_modules/lib0/*"],
"lib0/set.js": ["node_modules/lib0/set.js"],
"lib0/function.js": ["node_modules/lib0/function.js"]
}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */